Add SecretLeaseContainer to renew leases and rotate secrets.

We now provide an event-driven container to request secrets with renewal and rotation on terminal lease expiration.

See gh-50.
This commit is contained in:
Mark Paluch
2017-03-02 21:40:37 +01:00
parent f8a4e3a4fd
commit 81ade23b8c
16 changed files with 2047 additions and 0 deletions

View File

@@ -0,0 +1,727 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import java.util.concurrent.atomic.AtomicReference;
import lombok.extern.apachecommons.CommonsLog;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.TriggerContext;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.vault.VaultException;
import org.springframework.vault.client.VaultResponses;
import org.springframework.vault.core.RestOperationsCallback;
import org.springframework.vault.core.VaultOperations;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
import org.springframework.vault.core.lease.domain.RequestedSecret.Mode;
import org.springframework.vault.core.lease.event.LeaseErrorListener;
import org.springframework.vault.core.lease.event.LeaseListener;
import org.springframework.vault.support.VaultResponseSupport;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestOperations;
/**
* Event-based container to request secrets from Vault and renew the associated
* {@link Lease}. Secrets can be rotated, depending on the requested
* {@link RequestedSecret.Mode}.
*
* Usage example:
*
* <pre>
* <code>
SecretLeaseContainer container = new SecretLeaseContainer(vaultOperations,
taskScheduler);
final RequestedSecret requestedSecret = container
.requestRotatingSecret("mysql/creds/my-role");
container.addLeaseListener(new LeaseListenerAdapter() {
&#64;Override
public void onLeaseEvent(LeaseEvent leaseEvent) {
if (requestedSecret == leaseEvent.getSource()) {
if (leaseEvent instanceof LeaseCreatedEvent) {
}
if (leaseEvent instanceof LeaseExpiredEvent) {
}
}
}
});
container.afterPropertiesSet();
container.start(); // events are triggered after starting the container
* </code>
* </pre>
* <p>
* This container keeps track over {@link RequestedSecret}s and requests secrets upon
* {@link #start()}. Leases qualified for {@link Lease#isRenewable() renewal} are renewed
* by this container applying {@code minRenewalSeconds}/{@code expiryThresholdSeconds} on
* a {@link TaskScheduler background thread}.
* <p>
* Requests for secrets can define either renewal or rotation. Renewable leases are
* renewed until expiry. Rotating secrets renew their associated lease until expiry and
* request new secrets after expiry. Vault requires active interaction from a caller side
* to determine a secret is expired. Vault does not send any events. Expired secrets
* events can dispatch later than the actual expiry.
* <p>
* The container dispatches lease events to {@link LeaseListener} and
* {@link LeaseErrorListener}. Event notifications are dispatched either on the
* {@link #start() stating} {@link Thread} or worker threads used for background renewal.
*
* Instances are thread-safe once {@link #afterPropertiesSet() initialized.}
*
* @author Mark Paluch
* @see RequestedSecret
* @see SecretLeaseEventPublisher
* @see Lease
*/
@CommonsLog
public class SecretLeaseContainer extends SecretLeaseEventPublisher
implements InitializingBean, DisposableBean {
private final static AtomicIntegerFieldUpdater<SecretLeaseContainer> UPDATER = AtomicIntegerFieldUpdater
.newUpdater(SecretLeaseContainer.class, "status");
private static final AtomicInteger poolId = new AtomicInteger();
private final static int STATUS_INITIAL = 0;
private final static int STATUS_STARTED = 1;
private final static int STATUS_DESTROYED = 2;
private final List<RequestedSecret> requestedSecrets = new CopyOnWriteArrayList<RequestedSecret>();
private final Map<RequestedSecret, LeaseRenewalScheduler> renewals = new ConcurrentHashMap<RequestedSecret, LeaseRenewalScheduler>();
private final VaultOperations operations;
private int minRenewalSeconds = 10;
private int expiryThresholdSeconds = 60;
private TaskScheduler taskScheduler;
private boolean manageTaskScheduler;
private volatile boolean initialized;
private volatile int status = STATUS_INITIAL;
/**
* Creates a new {@link SecretLeaseContainer} given {@link VaultOperations}.
*
* @param operations must not be {@literal null}.
*/
public SecretLeaseContainer(VaultOperations operations) {
Assert.notNull(operations, "VaultOperations must not be null");
this.operations = operations;
}
/**
* Creates a new {@link SecretLeaseContainer} given {@link VaultOperations} and
* {@link TaskScheduler}.
*
* @param operations must not be {@literal null}.
* @param taskScheduler must not be {@literal null}.
*/
public SecretLeaseContainer(VaultOperations operations, TaskScheduler taskScheduler) {
Assert.notNull(operations, "VaultOperations must not be null");
Assert.notNull(taskScheduler, "TaskScheduler must not be null");
this.operations = operations;
this.taskScheduler = 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;
}
public int getMinRenewalSeconds() {
return minRenewalSeconds;
}
public int getExpiryThresholdSeconds() {
return expiryThresholdSeconds;
}
/**
* Sets the {@link TaskScheduler} to use for scheduling and execution of lease
* renewals.
*
* @param taskScheduler must not be {@literal null}.
*/
public void setTaskScheduler(TaskScheduler taskScheduler) {
Assert.notNull(taskScheduler, "TaskScheduler must not be null");
this.taskScheduler = taskScheduler;
}
/**
* Request a renewable secret at {@code path}.
*
* @param path must not be {@literal null} or empty.
* @return the {@link RequestedSecret}.
*/
public RequestedSecret requestRenewableSecret(String path) {
RequestedSecret requestedSecret = RequestedSecret.renewable(path);
addRequestedSecret(requestedSecret);
return requestedSecret;
}
/**
* Request a rotating secret at {@code path}.
*
* @param path must not be {@literal null} or empty.
* @return the {@link RequestedSecret}.
*/
public RequestedSecret requestRotatingSecret(String path) {
RequestedSecret requestedSecret = RequestedSecret.rotating(path);
addRequestedSecret(requestedSecret);
return requestedSecret;
}
/**
* Add a {@link RequestedSecret}.
*
* @param requestedSecret must not be {@literal null}.
*/
public void addRequestedSecret(RequestedSecret requestedSecret) {
Assert.notNull(requestedSecret, "RequestedSecret must not be null");
this.requestedSecrets.add(requestedSecret);
if (initialized) {
LeaseRenewalScheduler leaseRenewalScheduler = new LeaseRenewalScheduler(
this.taskScheduler);
this.renewals.put(requestedSecret, leaseRenewalScheduler);
if (this.status == STATUS_STARTED) {
start(requestedSecret, leaseRenewalScheduler);
}
}
}
/**
* Start the {@link SecretLeaseContainer}. Starting the container will initially
* obtain secrets and leases for the requested secrets. A started container publishes
* events through {@link LeaseListener}. Additional secrets can be requested at any
* time.
* <p>
* Multiple {@link #start()} calls are synchronized to start the container only once.
* Container start requires {@link #afterPropertiesSet() initialization} and cannot be
* started once the container was {@link #destroy() destroyed}.
*
* @see #afterPropertiesSet()
* @see #stop()
*/
public void start() {
Assert.state(this.initialized, "Container is not initialized");
Assert.state(this.status != STATUS_DESTROYED,
"Container is destroyed and cannot be started");
Map<RequestedSecret, LeaseRenewalScheduler> renewals = new HashMap<RequestedSecret, LeaseRenewalScheduler>(
this.renewals);
if (UPDATER.compareAndSet(this, STATUS_INITIAL, STATUS_STARTED)) {
for (Entry<RequestedSecret, LeaseRenewalScheduler> entry : renewals
.entrySet()) {
start(entry.getKey(), entry.getValue());
}
}
}
private void start(RequestedSecret requestedSecret,
LeaseRenewalScheduler renewalScheduler) {
VaultResponseSupport<Map<String, Object>> secrets = doGetSecrets(requestedSecret);
if (secrets != null) {
Lease lease = !StringUtils.hasText(secrets.getLeaseId()) ? Lease.none()
: Lease.of(secrets.getLeaseId(), secrets.getLeaseDuration(),
secrets.isRenewable());
potentiallyScheduleLeaseRenewal(requestedSecret, lease, renewalScheduler);
onSecretsObtained(requestedSecret, lease, secrets.getData());
}
}
/**
* Stop the {@link SecretLeaseContainer}. Stopping the container will stop lease
* renewal, secrets rotation and event publishing. Active leases are not expired.
* <p>
* Multiple {@link #stop()} calls are synchronized to stop the container only once.
*
* @see #start()
*/
public void stop() {
if (UPDATER.compareAndSet(this, STATUS_STARTED, STATUS_INITIAL)) {
for (LeaseRenewalScheduler leaseRenewal : this.renewals.values()) {
leaseRenewal.disableScheduleRenewal();
}
}
}
@Override
public void afterPropertiesSet() throws Exception {
if (!this.initialized) {
super.afterPropertiesSet();
this.initialized = true;
if (this.taskScheduler == null) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setDaemon(true);
scheduler.setThreadNamePrefix(String.format("%s-%d-",
getClass().getSimpleName(), poolId.incrementAndGet()));
scheduler.afterPropertiesSet();
this.taskScheduler = scheduler;
this.manageTaskScheduler = true;
}
for (RequestedSecret requestedSecret : requestedSecrets) {
this.renewals.put(requestedSecret,
new LeaseRenewalScheduler(this.taskScheduler));
}
}
}
/**
* Shutdown this {@link SecretLeaseContainer}, disable lease renewal and revoke
* leases.
*/
@Override
public void destroy() throws Exception {
int status = this.status;
if (status == STATUS_INITIAL || status == STATUS_STARTED) {
if (UPDATER.compareAndSet(this, status, STATUS_DESTROYED)) {
for (Entry<RequestedSecret, LeaseRenewalScheduler> entry : renewals
.entrySet()) {
Lease lease = entry.getValue().getLease();
entry.getValue().disableScheduleRenewal();
doRevokeLease(entry.getKey(), lease);
}
if (manageTaskScheduler) {
if (this.taskScheduler instanceof DisposableBean) {
((DisposableBean) this.taskScheduler).destroy();
this.taskScheduler = null;
}
}
}
}
}
void potentiallyScheduleLeaseRenewal(final RequestedSecret requestedSecret,
final Lease lease, final LeaseRenewalScheduler leaseRenewal) {
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(requestedSecret, lease);
if (newLease == null) {
return null;
}
potentiallyScheduleLeaseRenewal(requestedSecret, newLease,
leaseRenewal);
onAfterLeaseRenewed(requestedSecret, newLease);
return newLease;
}
}, lease, getMinRenewalSeconds(), getExpiryThresholdSeconds());
}
}
// -------------------------------------------------------------------------
// Implementation hooks and helper methods
// -------------------------------------------------------------------------
/**
* Retrieve secrets from {@link VaultOperations}.
*
* @param requestedSecret the {@link RequestedSecret} providing the secret
* {@code path}.
* @return the response.
*/
protected VaultResponseSupport<Map<String, Object>> doGetSecrets(
RequestedSecret requestedSecret) {
try {
return this.operations.read(requestedSecret.getPath());
}
catch (RuntimeException e) {
onError(requestedSecret, Lease.none(), e);
return null;
}
}
/**
* Renew a {@link Lease} for a {@link RequestedSecret}.
*
* @param requestedSecret the requested secret.
* @param lease the lease.
* @return the new lease or {@literal null} if expired/secret cannot be rotated.
*/
protected Lease doRenewLease(RequestedSecret requestedSecret, final Lease lease) {
try {
ResponseEntity<Map<String, Object>> entity = operations.doWithSession(
new RestOperationsCallback<ResponseEntity<Map<String, Object>>>() {
@Override
@SuppressWarnings("unchecked")
public ResponseEntity<Map<String, Object>> doWithRestOperations(
RestOperations restOperations) {
return (ResponseEntity) restOperations.exchange(
"/sys/renew/{leaseId}", HttpMethod.PUT, null,
Map.class, lease.getLeaseId());
}
});
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 (leaseDuration == null || leaseDuration.intValue() < minRenewalSeconds) {
onLeaseExpired(requestedSecret, lease);
return null;
}
return Lease.of(leaseId, leaseDuration.longValue(), renewable);
}
catch (HttpStatusCodeException e) {
if (e.getStatusCode() == HttpStatus.BAD_REQUEST) {
onLeaseExpired(requestedSecret, lease);
}
onError(requestedSecret, lease,
new VaultException(String.format("Cannot renew lease: %s",
VaultResponses.getError(e.getResponseBodyAsString()))));
}
catch (RuntimeException e) {
onError(requestedSecret, lease, e);
}
return null;
}
/**
* Hook method called when a {@link Lease} expired. The default implementation is to
* notify {@link LeaseListener}. Implementations can override this method in
* subclasses.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
protected void onLeaseExpired(RequestedSecret requestedSecret, Lease lease) {
super.onLeaseExpired(requestedSecret, lease);
if (requestedSecret.getMode() == Mode.ROTATE) {
start(requestedSecret, renewals.get(requestedSecret));
}
}
/**
* Revoke the {@link Lease}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
protected void doRevokeLease(RequestedSecret requestedSecret, final Lease lease) {
try {
onBeforeLeaseRevocation(requestedSecret, lease);
operations.doWithSession(
new RestOperationsCallback<ResponseEntity<Map<String, Object>>>() {
@Override
@SuppressWarnings("unchecked")
public ResponseEntity<Map<String, Object>> doWithRestOperations(
RestOperations restOperations) {
return (ResponseEntity) restOperations.exchange(
"/sys/revoke/{leaseId}", HttpMethod.PUT, null,
Map.class, lease.getLeaseId());
}
});
onAfterLeaseRevocation(requestedSecret, lease);
}
catch (HttpStatusCodeException e) {
onError(requestedSecret, lease,
new VaultException(String.format("Cannot revoke lease: %s",
VaultResponses.getError(e.getResponseBodyAsString()))));
}
catch (RuntimeException e) {
onError(requestedSecret, lease, e);
}
}
/**
* 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.
*/
@CommonsLog
static class LeaseRenewalScheduler {
private final TaskScheduler taskScheduler;
final AtomicReference<Lease> currentLeaseRef = new AtomicReference<Lease>();
final Map<Lease, ScheduledFuture<?>> schedules = new ConcurrentHashMap<Lease, ScheduledFuture<?>>();
/**
*
* @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) {
if (log.isDebugEnabled()) {
log.debug(String.format(
"Scheduling renewal for lease %s, lease duration %d",
lease.getLeaseId(), lease.getLeaseDuration()));
}
Lease currentLease = this.currentLeaseRef.get();
this.currentLeaseRef.set(lease);
if (currentLease != null) {
cancelSchedule(currentLease);
}
ScheduledFuture<?> scheduledFuture = taskScheduler.schedule(new Runnable() {
@Override
public void run() {
try {
schedules.remove(lease);
if (currentLeaseRef.get() != lease) {
log.debug("Current lease has changed. Skipping renewal");
return;
}
if (log.isDebugEnabled()) {
log.debug(String.format("Renewing lease %s",
lease.getLeaseId()));
}
currentLeaseRef.compareAndSet(lease,
renewLease.renewLease(lease));
}
catch (Exception e) {
log.error(String.format("Cannot renew lease %s",
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) {
if (log.isDebugEnabled()) {
log.debug(String.format(
"Canceling previously registered schedule for lease %s",
lease.getLeaseId()));
}
scheduledFuture.cancel(false);
}
}
/**
* Disables schedule for already scheduled renewals.
*/
public void disableScheduleRenewal() {
currentLeaseRef.set(null);
Set<Lease> leases = new HashSet<Lease>(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();
}
public Lease getLease() {
return currentLeaseRef.get();
}
}
/**
* This one-shot trigger creates only one execution time to trigger an execution only
* once.
*/
static class OneShotTrigger implements Trigger {
private final static AtomicIntegerFieldUpdater<OneShotTrigger> UPDATER = AtomicIntegerFieldUpdater
.newUpdater(OneShotTrigger.class, "status");
private final static int STATUS_ARMED = 0;
private final static int STATUS_FIRED = 1;
// see AtomicIntegerFieldUpdater UPDATER
private volatile int status = 0;
private final long seconds;
OneShotTrigger(long seconds) {
this.seconds = seconds;
}
@Override
public Date nextExecutionTime(TriggerContext triggerContext) {
if (UPDATER.compareAndSet(this, STATUS_ARMED, STATUS_FIRED)) {
return new Date(
System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(seconds));
}
return null;
}
}
/**
* Strategy interface to renew a {@link Lease}.
*/
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,221 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import lombok.extern.apachecommons.CommonsLog;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.Assert;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
import org.springframework.vault.core.lease.event.AfterSecretLeaseRenewedEvent;
import org.springframework.vault.core.lease.event.AfterSecretLeaseRevocationEvent;
import org.springframework.vault.core.lease.event.BeforeSecretLeaseRevocationEvent;
import org.springframework.vault.core.lease.event.LeaseErrorListener;
import org.springframework.vault.core.lease.event.LeaseListener;
import org.springframework.vault.core.lease.event.SecretLeaseCreatedEvent;
import org.springframework.vault.core.lease.event.SecretLeaseErrorEvent;
import org.springframework.vault.core.lease.event.SecretLeaseEvent;
import org.springframework.vault.core.lease.event.SecretLeaseExpiredEvent;
/**
* Publisher for {@link SecretLeaseEvent}s.
* <p>
* This publisher dispatches events to {@link LeaseListener} and
* {@link LeaseErrorListener}. Instances are thread-safe once {@link #afterPropertiesSet()
* initialized}.
*
* @author Mark Paluch
* @see SecretLeaseEvent
* @see LeaseListener
* @see LeaseErrorListener
*/
public class SecretLeaseEventPublisher implements InitializingBean {
private final Set<LeaseListener> leaseListeners = new CopyOnWriteArraySet<LeaseListener>();
private final Set<LeaseErrorListener> leaseErrorListeners = new CopyOnWriteArraySet<LeaseErrorListener>();
/**
* Add a {@link LeaseListener} to the container. The listener starts receiving events
* as soon as possible.
*
* @param listener lease listener, must not be {@literal null}.
*/
public void addLeaseListener(LeaseListener listener) {
Assert.notNull(listener, "LeaseListener must not be null");
this.leaseListeners.add(listener);
}
/**
* Remove a {@link LeaseListener}.
*
* @param listener must not be {@literal null}.
*/
public void removeLeaseListener(LeaseListener listener) {
this.leaseListeners.remove(listener);
}
/**
* Add a {@link LeaseErrorListener} to the container. The listener starts receiving
* events as soon as possible.
*
* @param listener lease listener, must not be {@literal null}.
*/
public void addErrorListener(LeaseErrorListener listener) {
Assert.notNull(listener, "LeaseListener must not be null");
this.leaseErrorListeners.add(listener);
}
/**
* Remove a {@link LeaseErrorListener}.
*
* @param listener must not be {@literal null}.
*/
public void removeLeaseErrorListener(LeaseErrorListener listener) {
this.leaseErrorListeners.remove(listener);
}
@Override
public void afterPropertiesSet() throws Exception {
if (this.leaseErrorListeners.isEmpty()) {
addErrorListener(LoggingErrorListener.INSTANCE);
}
}
/**
* Hook method called when secrets were obtained. The default implementation is to
* notify {@link LeaseListener}. Implementations can override this method in
* subclasses.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
* @param body must not be {@literal null}.
*/
protected void onSecretsObtained(RequestedSecret requestedSecret, Lease lease,
Map<String, Object> body) {
for (LeaseListener leaseListener : leaseListeners) {
leaseListener.onLeaseEvent(
new SecretLeaseCreatedEvent(requestedSecret, lease, body));
}
}
/**
* Hook method called when a {@link Lease} is renewed. The default implementation is
* to notify {@link LeaseListener}. Implementations can override this method in
* subclasses.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
protected void onAfterLeaseRenewed(RequestedSecret requestedSecret, Lease lease) {
for (LeaseListener leaseListener : leaseListeners) {
leaseListener.onLeaseEvent(
new AfterSecretLeaseRenewedEvent(requestedSecret, lease));
}
}
/**
* Hook method called before triggering revocation for a {@link Lease}. The default
* implementation is to notify {@link LeaseListener}. Implementations can override
* this method in subclasses.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
protected void onBeforeLeaseRevocation(RequestedSecret requestedSecret, Lease lease) {
for (LeaseListener leaseListener : leaseListeners) {
leaseListener.onLeaseEvent(
new BeforeSecretLeaseRevocationEvent(requestedSecret, lease));
}
}
/**
* Hook method called after triggering revocation for a {@link Lease}. The default
* implementation is to notify {@link LeaseListener}. Implementations can override
* this method in subclasses.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
protected void onAfterLeaseRevocation(RequestedSecret requestedSecret, Lease lease) {
for (LeaseListener leaseListener : leaseListeners) {
leaseListener.onLeaseEvent(
new AfterSecretLeaseRevocationEvent(requestedSecret, lease));
}
}
/**
* Hook method called when a {@link Lease} expires. The default implementation is to
* notify {@link LeaseListener}. Implementations can override this method in
* subclasses.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
protected void onLeaseExpired(RequestedSecret requestedSecret, Lease lease) {
for (LeaseListener leaseListener : leaseListeners) {
leaseListener
.onLeaseEvent(new SecretLeaseExpiredEvent(requestedSecret, lease));
}
}
/**
* Hook method called when an error occurred during secret retrieval, lease renewal,
* and other Vault interactions. The default implementation is to notify
* {@link LeaseErrorListener}. Implementations can override this method in subclasses.
*
* @param requestedSecret must not be {@literal null}.
* @param lease may be {@literal null}
* @param e the causing exception.
*/
protected void onError(RequestedSecret requestedSecret, Lease lease, Exception e) {
for (LeaseErrorListener leaseErrorListener : leaseErrorListeners) {
leaseErrorListener.onLeaseError(
new SecretLeaseErrorEvent(requestedSecret, lease, e), e);
}
}
/**
* Simple {@link LeaseErrorListener} implementation to log errors.
*/
@CommonsLog
public enum LoggingErrorListener implements LeaseErrorListener {
INSTANCE;
@Override
public void onLeaseError(SecretLeaseEvent leaseEvent, Exception exception) {
log.warn(String.format("[%s] %s %s", leaseEvent.getSource(),
leaseEvent.getLease(), exception.getMessage()), exception);
}
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.domain;
import org.springframework.util.Assert;
/**
* A lease abstracting the lease Id, duration and renewability.
*
* @author Mark Paluch
*/
public class Lease {
private final static Lease NONE = new Lease(null, 0, false);
private final String leaseId;
private final long leaseDuration;
private final boolean renewable;
private Lease(String leaseId, long leaseDuration, boolean renewable) {
this.leaseId = leaseId;
this.leaseDuration = leaseDuration;
this.renewable = renewable;
}
/**
* Create 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) {
Assert.hasText(leaseId, "LeaseId must not be empty");
return new Lease(leaseId, leaseDuration, renewable);
}
/**
* Factory method to return a non-renewable, zero-duration {@link Lease}.
*
* @return a non-renewable, zero-duration {@link Lease}.
*/
public static Lease none() {
return NONE;
}
/**
* @return the lease Id
*/
public String getLeaseId() {
return leaseId;
}
/**
* @return the lease duration in seconds.
*/
public long getLeaseDuration() {
return leaseDuration;
}
/**
*
* @return {@literal true} if the lease is renewable.
*/
public boolean isRenewable() {
return renewable;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof Lease))
return false;
Lease lease = (Lease) o;
if (leaseDuration != lease.leaseDuration)
return false;
if (renewable != lease.renewable)
return false;
return leaseId != null ? leaseId.equals(lease.leaseId) : lease.leaseId == null;
}
@Override
public int hashCode() {
int result = leaseId != null ? leaseId.hashCode() : 0;
result = 31 * result + (int) (leaseDuration ^ (leaseDuration >>> 32));
result = 31 * result + (renewable ? 1 : 0);
return result;
}
@Override
public String toString() {
StringBuffer sb = new StringBuffer();
sb.append(getClass().getSimpleName());
sb.append(" [leaseId='").append(leaseId).append('\'');
sb.append(", leaseDuration=").append(leaseDuration);
sb.append(", renewable=").append(renewable);
sb.append(']');
return sb.toString();
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.domain;
import org.springframework.util.Assert;
/**
* Represents a requested secret from a specific Vault path associated with a lease
* {@link Mode}.
* <p>
* A {@link RequestedSecret} can be renewing or rotating.
*
* @author Mark Paluch
* @see Mode
* @see Lease#isRenewable()
*/
public class RequestedSecret {
private final String path;
private final Mode mode;
private RequestedSecret(String path, Mode mode) {
Assert.hasText(path, "Path must not be null or empty");
this.path = path;
this.mode = mode;
}
/**
* Create a renewable {@link RequestedSecret} at {@code path}. A lease associated with
* this secret will be renewed if the lease is qualified for renewal. The lease is no
* longer valid after expiry.
*
* @param path must not be {@literal null} or empty.
* @return the renewable {@link RequestedSecret}.
*/
public static RequestedSecret renewable(String path) {
return new RequestedSecret(path, Mode.RENEW);
}
/**
* Create a rotating {@link RequestedSecret} at {@code path}. A lease associated with
* this secret will be renewed if the lease is qualified for renewal. Once the lease
* expires, a new secret with a new lease is obtained.
*
* @param path must not be {@literal null} or empty.
* @return the rotating {@link RequestedSecret}.
*/
public static RequestedSecret rotating(String path) {
return new RequestedSecret(path, Mode.ROTATE);
}
/**
* @return the Vault path of the requested secret.
*/
public String getPath() {
return path;
}
/**
* @return lease mode.
* @see Mode
*/
public Mode getMode() {
return mode;
}
@Override
public String toString() {
final StringBuffer sb = new StringBuffer();
sb.append(getClass().getSimpleName());
sb.append(" [path='").append(path).append('\'');
sb.append(", mode=").append(mode);
sb.append(']');
return sb.toString();
}
public enum Mode {
/**
* Renew lease of the requested secret until secret expires its max lease time.
*/
RENEW,
/**
* Renew lease of the requested secret. Obtains new secret along a new lease once
* the previous lease expires its max lease time.
*/
ROTATE;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
/**
* Event published after renewing a {@link Lease} for a {@link RequestedSecret}. The
* secrets associated with {@link Lease} should be considered valid and the lease extended
* when this event is received.
*
* @author Mark Paluch
*/
public class AfterSecretLeaseRenewedEvent extends SecretLeaseEvent {
private static final long serialVersionUID = 1L;
/**
* Create a new {@link SecretLeaseExpiredEvent} given {@link RequestedSecret} and
* {@link Lease}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
public AfterSecretLeaseRenewedEvent(RequestedSecret requestedSecret, Lease lease) {
super(requestedSecret, lease);
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
/**
* Event published after revoking a {@link Lease} for a {@link RequestedSecret}. The
* secrets associated with {@link Lease} should be considered invalid when this event is
* received.
*
* @author Mark Paluch
*/
public class AfterSecretLeaseRevocationEvent extends SecretLeaseEvent {
private static final long serialVersionUID = 1L;
/**
* Create a new {@link SecretLeaseExpiredEvent} given {@link RequestedSecret} and
* {@link Lease}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
public AfterSecretLeaseRevocationEvent(RequestedSecret requestedSecret, Lease lease) {
super(requestedSecret, lease);
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
/**
* Event published before revoking a {@link Lease} for a {@link RequestedSecret}. The
* secrets associated with {@link Lease} should be considered still valid when this event
* is received.
*
* @author Mark Paluch
* @see AfterSecretLeaseRevocationEvent
*/
public class BeforeSecretLeaseRevocationEvent extends SecretLeaseEvent {
private static final long serialVersionUID = 1L;
/**
* Create a new {@link SecretLeaseExpiredEvent} given {@link RequestedSecret} and
* {@link Lease}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
public BeforeSecretLeaseRevocationEvent(RequestedSecret requestedSecret,
Lease lease) {
super(requestedSecret, lease);
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
/**
* Listener for Vault exceptional {@link SecretLeaseEvent}s.
*
* @author Mark Paluch
*/
public interface LeaseErrorListener {
/**
* Callback for a {@link SecretLeaseEvent}
*
* @param leaseEvent the event object, must not be {@literal null}.
* @param exception the thrown {@link Exception}.
*/
void onLeaseError(SecretLeaseEvent leaseEvent, Exception exception);
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
/**
* Listener for Vault {@link SecretLeaseEvent}s.
*
* @author Mark Paluch
*/
public interface LeaseListener {
/**
* Callback for a {@link SecretLeaseEvent}
*
* @param leaseEvent the event object, must not be {@literal null}.
*/
void onLeaseEvent(SecretLeaseEvent leaseEvent);
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
/**
* Empty listener adapter implementing {@link LeaseListener} and
* {@link LeaseErrorListener}. Typically used to facilitate interface evolution.
*
* @author Mark Paluch
* @see SecretLeaseEvent
*/
public abstract class LeaseListenerAdapter implements LeaseListener, LeaseErrorListener {
@Override
public void onLeaseEvent(SecretLeaseEvent leaseEvent) {
// empty listener method
}
@Override
public void onLeaseError(SecretLeaseEvent leaseEvent, Exception exception) {
// empty listener method
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
/**
* Event published after creating a {@link Lease} for a {@link RequestedSecret}.
*
* @author Mark Paluch
*/
public class SecretLeaseCreatedEvent extends SecretLeaseEvent {
private static final long serialVersionUID = 1L;
private final Map<String, Object> secrets;
/**
* Create a new {@link SecretLeaseExpiredEvent} given {@link RequestedSecret},
* {@link Lease} and {@code secrets}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
public SecretLeaseCreatedEvent(RequestedSecret requestedSecret, Lease lease,
Map<String, Object> secrets) {
super(requestedSecret, lease);
this.secrets = Collections.unmodifiableMap(new HashMap<String, Object>(secrets));
}
public Map<String, Object> getSecrets() {
return secrets;
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
/**
* Event published when caught an {@link Exception} during secret retrieval and lease
* interaction.
*
* @author Mark Paluch
*/
public class SecretLeaseErrorEvent extends SecretLeaseEvent {
private static final long serialVersionUID = 1L;
private final Throwable exception;
/**
* Create a new {@link SecretLeaseExpiredEvent} given {@link RequestedSecret},
* {@link Lease} and {@link Throwable}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease can be {@literal null}.
* @param exception must not be {@literal null}.
*/
public SecretLeaseErrorEvent(RequestedSecret requestedSecret, Lease lease,
Throwable exception) {
super(requestedSecret, lease);
this.exception = exception;
}
public Throwable getException() {
return exception;
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
import org.springframework.context.ApplicationEvent;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
/**
* Abstract base class for {@link Lease} based
* events.
*
* @author Mark Paluch
*/
public abstract class SecretLeaseEvent extends ApplicationEvent {
private final Lease lease;
/**
* Create a new {@link SecretLeaseExpiredEvent} given {@link RequestedSecret} and
* {@link Lease}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
protected SecretLeaseEvent(RequestedSecret requestedSecret, Lease lease) {
super(requestedSecret);
this.lease = lease;
}
@Override
public RequestedSecret getSource() {
return (RequestedSecret) super.getSource();
}
public Lease getLease() {
return lease;
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease.event;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
/**
* Event published after an expired {@link Lease} for a {@link RequestedSecret} was
* observed.
*
* @author Mark Paluch
*/
public class SecretLeaseExpiredEvent extends SecretLeaseEvent {
private static final long serialVersionUID = 1L;
/**
* Create a new {@link SecretLeaseExpiredEvent} given {@link RequestedSecret} and
* {@link Lease}.
*
* @param requestedSecret must not be {@literal null}.
* @param lease must not be {@literal null}.
*/
public SecretLeaseExpiredEvent(RequestedSecret requestedSecret, Lease lease) {
super(requestedSecret, lease);
}
}

View File

@@ -0,0 +1,450 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.vault.core.lease;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
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.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
import org.springframework.vault.VaultException;
import org.springframework.vault.core.RestOperationsCallback;
import org.springframework.vault.core.VaultOperations;
import org.springframework.vault.core.lease.domain.Lease;
import org.springframework.vault.core.lease.domain.RequestedSecret;
import org.springframework.vault.core.lease.event.AfterSecretLeaseRevocationEvent;
import org.springframework.vault.core.lease.event.BeforeSecretLeaseRevocationEvent;
import org.springframework.vault.core.lease.event.LeaseListenerAdapter;
import org.springframework.vault.core.lease.event.SecretLeaseCreatedEvent;
import org.springframework.vault.core.lease.event.SecretLeaseEvent;
import org.springframework.vault.core.lease.event.SecretLeaseExpiredEvent;
import org.springframework.vault.support.VaultResponse;
import org.springframework.web.client.HttpClientErrorException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link SecretLeaseContainer}.
*
* @author Mark Paluch
*/
@RunWith(MockitoJUnitRunner.class)
public class SecretLeaseContainerUnitTests {
@Mock
private VaultOperations vaultOperations;
@Mock
private TaskScheduler taskScheduler;
@Mock
private ScheduledFuture scheduledFuture;
@Mock
private LeaseListenerAdapter leaseListenerAdapter;
@Captor
private ArgumentCaptor<SecretLeaseEvent> captor;
private RequestedSecret requestedSecret = RequestedSecret.renewable("my-secret");
private SecretLeaseContainer secretLeaseContainer;
@Before
public void before() throws Exception {
secretLeaseContainer = new SecretLeaseContainer(vaultOperations, taskScheduler);
secretLeaseContainer.addLeaseListener(leaseListenerAdapter);
secretLeaseContainer.addErrorListener(leaseListenerAdapter);
secretLeaseContainer.afterPropertiesSet();
}
@Test
public void shouldWorkIfNoSecretsRequested() {
secretLeaseContainer.start();
verifyZeroInteractions(leaseListenerAdapter);
}
@Test
public void shouldWorkIfNoSecretsFound() {
secretLeaseContainer.start();
secretLeaseContainer.requestRenewableSecret(requestedSecret.getPath());
verifyZeroInteractions(leaseListenerAdapter);
}
@Test
public void shouldAcceptSecretsWithoutLease() {
VaultResponse secrets = new VaultResponse();
secrets.setData(Collections.singletonMap("key", (Object) "value"));
when(vaultOperations.read(requestedSecret.getPath())).thenReturn(secrets);
secretLeaseContainer.addRequestedSecret(requestedSecret);
secretLeaseContainer.start();
verifyZeroInteractions(taskScheduler);
verify(leaseListenerAdapter).onLeaseEvent(captor.capture());
SecretLeaseCreatedEvent leaseCreatedEvent = (SecretLeaseCreatedEvent) captor
.getValue();
assertThat(leaseCreatedEvent.getSource()).isEqualTo(requestedSecret);
assertThat(leaseCreatedEvent.getLease()).isNotNull();
assertThat(leaseCreatedEvent.getSecrets()).containsKey("key");
}
@Test
public void shouldAcceptSecretsWithStaticLease() {
VaultResponse secrets = new VaultResponse();
secrets.setLeaseId("lease");
secrets.setRenewable(false);
secrets.setData(Collections.singletonMap("key", (Object) "value"));
when(vaultOperations.read(requestedSecret.getPath())).thenReturn(secrets);
secretLeaseContainer.addRequestedSecret(requestedSecret);
secretLeaseContainer.start();
verifyZeroInteractions(taskScheduler);
verify(leaseListenerAdapter).onLeaseEvent(captor.capture());
SecretLeaseCreatedEvent leaseCreatedEvent = (SecretLeaseCreatedEvent) captor
.getValue();
assertThat(leaseCreatedEvent.getSource()).isEqualTo(requestedSecret);
assertThat(leaseCreatedEvent.getLease()).isNotNull();
assertThat(leaseCreatedEvent.getSecrets()).containsKey("key");
}
@Test
public void shouldPropagateErrorsToListenerOnInitialRetrieval() {
VaultException e = new VaultException("error");
when(vaultOperations.read(requestedSecret.getPath())).thenThrow(e);
secretLeaseContainer.addRequestedSecret(requestedSecret);
secretLeaseContainer.start();
verify(leaseListenerAdapter).onLeaseError(captor.capture(), eq(e));
verifyNoMoreInteractions(leaseListenerAdapter);
SecretLeaseEvent leaseEvent = captor.getValue();
assertThat(leaseEvent.getSource()).isEqualTo(requestedSecret);
assertThat(leaseEvent.getLease()).isEqualTo(Lease.none());
}
@Test
@SuppressWarnings("unchecked")
public void shouldAcceptSecretsWithRenewableLease() {
when(taskScheduler.schedule(any(Runnable.class), any(Trigger.class)))
.thenReturn(scheduledFuture);
when(vaultOperations.read(requestedSecret.getPath())).thenReturn(createSecrets());
secretLeaseContainer.addRequestedSecret(requestedSecret);
secretLeaseContainer.start();
verify(taskScheduler).schedule(any(Runnable.class), any(Trigger.class));
}
@Test
public void shouldRenewLease() {
prepareRenewal();
when(vaultOperations.doWithSession(any(RestOperationsCallback.class)))
.thenReturn(getResponseEntity("new_lease", true, 70, HttpStatus.OK));
secretLeaseContainer.start();
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
public void shouldNotRenewExpiringLease() {
prepareRenewal();
when(vaultOperations.doWithSession(any(RestOperationsCallback.class)))
.thenReturn(getResponseEntity("new_lease", true, 5, HttpStatus.OK));
secretLeaseContainer.start();
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskScheduler).schedule(captor.capture(), any(Trigger.class));
captor.getValue().run();
verifyZeroInteractions(scheduledFuture);
verify(taskScheduler, times(1)).schedule(captor.capture(), any(Trigger.class));
verify(leaseListenerAdapter).onLeaseEvent(any(SecretLeaseCreatedEvent.class));
verify(leaseListenerAdapter).onLeaseEvent(any(SecretLeaseExpiredEvent.class));
}
@Test
public void shouldNotRotateExpiringLease() {
when(taskScheduler.schedule(any(Runnable.class), any(Trigger.class)))
.thenReturn(scheduledFuture);
VaultResponse first = createSecrets();
VaultResponse second = createSecrets();
second.setData(Collections.singletonMap("foo", (Object) "bar"));
when(vaultOperations.read(requestedSecret.getPath())).thenReturn(first, second);
when(vaultOperations.doWithSession(any(RestOperationsCallback.class)))
.thenReturn(getResponseEntity("new_lease", true, 5, HttpStatus.OK));
secretLeaseContainer.requestRotatingSecret("my-secret");
secretLeaseContainer.start();
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskScheduler).schedule(captor.capture(), any(Trigger.class));
captor.getValue().run();
verify(taskScheduler, times(2)).schedule(captor.capture(), any(Trigger.class));
ArgumentCaptor<SecretLeaseCreatedEvent> createdEvents = ArgumentCaptor
.forClass(SecretLeaseCreatedEvent.class);
verify(leaseListenerAdapter, times(3)).onLeaseEvent(createdEvents.capture());
List<SecretLeaseCreatedEvent> events = createdEvents.getAllValues();
assertThat(events).hasSize(3);
assertThat(events.get(0)).isInstanceOf(SecretLeaseCreatedEvent.class);
assertThat(events.get(0).getSecrets()).containsOnlyKeys("key");
assertThat(events.get(1)).isInstanceOf(SecretLeaseExpiredEvent.class);
assertThat(events.get(2)).isInstanceOf(SecretLeaseCreatedEvent.class);
assertThat(events.get(2).getSecrets()).containsOnlyKeys("foo");
}
@Test
public void scheduleRenewalShouldApplyExpiryThreshold() {
prepareRenewal();
secretLeaseContainer.start();
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
public void shouldPublishRenewalErrors() {
prepareRenewal();
when(vaultOperations.doWithSession(any(RestOperationsCallback.class)))
.thenThrow(new HttpClientErrorException(HttpStatus.I_AM_A_TEAPOT));
secretLeaseContainer.start();
ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
verify(taskScheduler).schedule(runnableCaptor.capture(), any(Trigger.class));
runnableCaptor.getValue().run();
verify(leaseListenerAdapter).onLeaseEvent(any(SecretLeaseCreatedEvent.class));
verify(leaseListenerAdapter).onLeaseError(captor.capture(),
any(VaultException.class));
verifyNoMoreInteractions(leaseListenerAdapter);
SecretLeaseEvent leaseEvent = captor.getValue();
assertThat(leaseEvent.getSource()).isEqualTo(requestedSecret);
assertThat(leaseEvent.getLease()).isNotNull();
}
@Test
@SuppressWarnings("unchecked")
public void subsequentScheduleRenewalShouldApplyExpiryThreshold() {
prepareRenewal();
when(vaultOperations.doWithSession(any(RestOperationsCallback.class)))
.thenReturn(getResponseEntity("new_lease", true, 70, HttpStatus.OK));
secretLeaseContainer.start();
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
public void scheduleRenewalShouldTriggerOnlyOnce() {
prepareRenewal();
secretLeaseContainer.start();
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
public void subsequentStartShouldNoOp() {
prepareRenewal();
secretLeaseContainer.start();
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskScheduler).schedule(captor.capture(), any(Trigger.class));
secretLeaseContainer.start();
verifyNoMoreInteractions(scheduledFuture);
verifyNoMoreInteractions(taskScheduler);
}
@Test
public void canceledRenewalShouldSkipRenewal() {
prepareRenewal();
secretLeaseContainer.start();
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskScheduler).schedule(captor.capture(), any(Trigger.class));
verify(vaultOperations).read(anyString());
secretLeaseContainer.stop();
verify(scheduledFuture).cancel(false);
captor.getValue().run();
verifyNoMoreInteractions(vaultOperations);
}
@Test
public void shouldDisableRenewalOnDisposal() throws Exception {
prepareRenewal();
secretLeaseContainer.start();
secretLeaseContainer.destroy();
verify(vaultOperations).doWithSession(any(RestOperationsCallback.class));
verify(scheduledFuture).cancel(false);
verify(leaseListenerAdapter).onLeaseEvent(any(SecretLeaseCreatedEvent.class));
verify(leaseListenerAdapter)
.onLeaseEvent(any(BeforeSecretLeaseRevocationEvent.class));
verify(leaseListenerAdapter)
.onLeaseEvent(any(AfterSecretLeaseRevocationEvent.class));
}
@SuppressWarnings("unchecked")
private void prepareRenewal() {
when(taskScheduler.schedule(any(Runnable.class), any(Trigger.class)))
.thenReturn(scheduledFuture);
when(vaultOperations.read(requestedSecret.getPath())).thenReturn(createSecrets());
secretLeaseContainer.addRequestedSecret(requestedSecret);
}
private ResponseEntity<Map<String, Object>> getResponseEntity(String leaseId,
Boolean renewable, Integer leaseDuration, HttpStatus httpStatus) {
Map<String, Object> body = new HashMap<String, Object>();
body.put("lease_id", leaseId);
body.put("renewable", renewable);
body.put("lease_duration", leaseDuration);
return getEntity(body, httpStatus);
}
private ResponseEntity<Map<String, Object>> getEntity(Map<String, Object> body,
HttpStatus status) {
return new ResponseEntity<Map<String, Object>>(body, status);
}
private VaultResponse createSecrets() {
VaultResponse secrets = new VaultResponse();
secrets.setLeaseId("lease");
secrets.setRenewable(true);
secrets.setLeaseDuration(100);
secrets.setData(Collections.singletonMap("key", (Object) "value"));
return secrets;
}
}