diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/env/LeaseAwareVaultPropertySource.java b/spring-vault-core/src/main/java/org/springframework/vault/core/env/LeaseAwareVaultPropertySource.java new file mode 100644 index 00000000..e69de29b diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java new file mode 100644 index 00000000..f81663b8 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java @@ -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: + * + *
+ * 
+SecretLeaseContainer container = new SecretLeaseContainer(vaultOperations,
+		taskScheduler);
+
+final RequestedSecret requestedSecret = container
+		.requestRotatingSecret("mysql/creds/my-role");
+container.addLeaseListener(new LeaseListenerAdapter() {
+	@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
+ * 
+ * 
+ *

+ * 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}. + *

+ * 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. + *

+ * 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 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 requestedSecrets = new CopyOnWriteArrayList(); + + private final Map renewals = new ConcurrentHashMap(); + + 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. + *

+ * 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 renewals = new HashMap( + this.renewals); + + if (UPDATER.compareAndSet(this, STATUS_INITIAL, STATUS_STARTED)) { + + for (Entry entry : renewals + .entrySet()) { + start(entry.getKey(), entry.getValue()); + } + } + } + + private void start(RequestedSecret requestedSecret, + LeaseRenewalScheduler renewalScheduler) { + + VaultResponseSupport> 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. + *

+ * 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 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> 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> entity = operations.doWithSession( + new RestOperationsCallback>>() { + + @Override + @SuppressWarnings("unchecked") + public ResponseEntity> doWithRestOperations( + RestOperations restOperations) { + return (ResponseEntity) restOperations.exchange( + "/sys/renew/{leaseId}", HttpMethod.PUT, null, + Map.class, lease.getLeaseId()); + } + }); + + Map body = entity.getBody(); + String leaseId = (String) body.get("lease_id"); + Number leaseDuration = (Number) body.get("lease_duration"); + boolean renewable = (Boolean) body.get("renewable"); + + if (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>>() { + + @Override + @SuppressWarnings("unchecked") + public ResponseEntity> 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 currentLeaseRef = new AtomicReference(); + + final Map> schedules = new ConcurrentHashMap>(); + + /** + * + * @param taskScheduler must not be {@literal null}. + */ + LeaseRenewalScheduler(TaskScheduler taskScheduler) { + this.taskScheduler = taskScheduler; + } + + /** + * Schedule {@link Lease} renewal. Previously registered renewal tasks are + * canceled to prevent renewal of stale {@link Lease}s. + * @param renewLease strategy to renew a {@link Lease}. + * @param lease the current {@link Lease}. + * @param minRenewalSeconds minimum number of seconds before renewing a + * {@link Lease}. This is to prevent too many renewals in a very short timeframe. + * @param expiryThresholdSeconds number of seconds to renew before {@link Lease}. + * expires. + */ + void scheduleRenewal(final RenewLease renewLease, final Lease lease, + final int minRenewalSeconds, final int expiryThresholdSeconds) { + + 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 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(); + } + + 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 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; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseEventPublisher.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseEventPublisher.java new file mode 100644 index 00000000..a44800c5 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseEventPublisher.java @@ -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. + *

+ * 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 leaseListeners = new CopyOnWriteArraySet(); + + private final Set leaseErrorListeners = new CopyOnWriteArraySet(); + + /** + * 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 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); + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/domain/Lease.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/domain/Lease.java new file mode 100644 index 00000000..9d77688f --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/domain/Lease.java @@ -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(); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/domain/RequestedSecret.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/domain/RequestedSecret.java new file mode 100644 index 00000000..b541c5ef --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/domain/RequestedSecret.java @@ -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}. + *

+ * 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; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/AfterSecretLeaseRenewedEvent.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/AfterSecretLeaseRenewedEvent.java new file mode 100644 index 00000000..9d656e96 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/AfterSecretLeaseRenewedEvent.java @@ -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); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/AfterSecretLeaseRevocationEvent.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/AfterSecretLeaseRevocationEvent.java new file mode 100644 index 00000000..cfdc5ee2 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/AfterSecretLeaseRevocationEvent.java @@ -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); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/BeforeSecretLeaseRevocationEvent.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/BeforeSecretLeaseRevocationEvent.java new file mode 100644 index 00000000..07905632 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/BeforeSecretLeaseRevocationEvent.java @@ -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); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseErrorListener.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseErrorListener.java new file mode 100644 index 00000000..46c54cf1 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseErrorListener.java @@ -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); +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseListener.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseListener.java new file mode 100644 index 00000000..c7c60c15 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseListener.java @@ -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); +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseListenerAdapter.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseListenerAdapter.java new file mode 100644 index 00000000..e14651dd --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/LeaseListenerAdapter.java @@ -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 + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseCreatedEvent.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseCreatedEvent.java new file mode 100644 index 00000000..e95855d1 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseCreatedEvent.java @@ -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 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 secrets) { + + super(requestedSecret, lease); + this.secrets = Collections.unmodifiableMap(new HashMap(secrets)); + } + + public Map getSecrets() { + return secrets; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseErrorEvent.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseErrorEvent.java new file mode 100644 index 00000000..7e10ebb8 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseErrorEvent.java @@ -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; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseEvent.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseEvent.java new file mode 100644 index 00000000..c494fdf8 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseEvent.java @@ -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; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseExpiredEvent.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseExpiredEvent.java new file mode 100644 index 00000000..834eb38b --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/event/SecretLeaseExpiredEvent.java @@ -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); + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/lease/SecretLeaseContainerUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/lease/SecretLeaseContainerUnitTests.java new file mode 100644 index 00000000..af2c5fa4 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/lease/SecretLeaseContainerUnitTests.java @@ -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 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 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 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 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 createdEvents = ArgumentCaptor + .forClass(SecretLeaseCreatedEvent.class); + verify(leaseListenerAdapter, times(3)).onLeaseEvent(createdEvents.capture()); + + List 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 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 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 runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(taskScheduler).schedule(runnableCaptor.capture(), any(Trigger.class)); + + runnableCaptor.getValue().run(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Trigger.class); + verify(taskScheduler, times(2)).schedule(any(Runnable.class), captor.capture()); + + assertThat(captor.getAllValues().get(0).nextExecutionTime(null)).isBetween( + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(35)), + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(41))); + + assertThat(captor.getAllValues().get(1).nextExecutionTime(null)).isBetween( + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(9)), + new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(11))); + } + + @Test + public void scheduleRenewalShouldTriggerOnlyOnce() { + + prepareRenewal(); + + secretLeaseContainer.start(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Trigger.class); + verify(taskScheduler).schedule(any(Runnable.class), captor.capture()); + + Trigger trigger = captor.getValue(); + + assertThat(trigger.nextExecutionTime(null)).isNotNull(); + assertThat(trigger.nextExecutionTime(null)).isNull(); + } + + @Test + public void subsequentStartShouldNoOp() { + + prepareRenewal(); + + secretLeaseContainer.start(); + + ArgumentCaptor 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 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> getResponseEntity(String leaseId, + Boolean renewable, Integer leaseDuration, HttpStatus httpStatus) { + + Map body = new HashMap(); + body.put("lease_id", leaseId); + body.put("renewable", renewable); + body.put("lease_duration", leaseDuration); + + return getEntity(body, httpStatus); + } + + private ResponseEntity> getEntity(Map body, + HttpStatus status) { + + return new ResponseEntity>(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; + } + +}