Skip to content

Commit 03a114b

Browse files
committed
Restart (rotate) leases after token rotation or expiry.
SecretLeaseContainer now restarts all secrets if the login token has been rotated or has expired as leases associated with a token are revoked upon login token expiry. Closes gh-815
1 parent 8babcd3 commit 03a114b

File tree

4 files changed

+342
-34
lines changed

4 files changed

+342
-34
lines changed

spring-vault-core/src/main/java/org/springframework/vault/config/AbstractVaultConfiguration.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.springframework.vault.authentication.ClientAuthentication;
3333
import org.springframework.vault.authentication.LifecycleAwareSessionManager;
3434
import org.springframework.vault.authentication.SessionManager;
35+
import org.springframework.vault.authentication.event.AuthenticationEventMulticaster;
3536
import org.springframework.vault.client.ClientHttpRequestFactoryFactory;
3637
import org.springframework.vault.client.RestTemplateBuilder;
3738
import org.springframework.vault.client.RestTemplateCustomizer;
@@ -168,8 +169,15 @@ public SecretLeaseContainer secretLeaseContainer() throws Exception {
168169

169170
SecretLeaseContainer secretLeaseContainer = new SecretLeaseContainer(
170171
getBeanFactory().getBean("vaultTemplate", VaultTemplate.class), getVaultThreadPoolTaskScheduler());
172+
SessionManager sessionManager = getBeanFactory().getBean("sessionManager", SessionManager.class);
171173

172174
secretLeaseContainer.afterPropertiesSet();
175+
176+
if (sessionManager instanceof AuthenticationEventMulticaster multicaster) {
177+
multicaster.addAuthenticationListener(secretLeaseContainer.getAuthenticationListener());
178+
multicaster.addErrorListener(secretLeaseContainer.getAuthenticationErrorListener());
179+
}
180+
173181
secretLeaseContainer.start();
174182

175183
return secretLeaseContainer;

spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java

Lines changed: 182 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,19 @@
1515
*/
1616
package org.springframework.vault.core.lease;
1717

18+
import java.time.Clock;
1819
import java.time.Duration;
1920
import java.time.Instant;
20-
import java.util.Date;
2121
import java.util.HashMap;
2222
import java.util.HashSet;
23+
import java.util.LinkedHashMap;
2324
import java.util.List;
2425
import java.util.Map;
2526
import java.util.Map.Entry;
2627
import java.util.Set;
2728
import java.util.concurrent.ConcurrentHashMap;
2829
import java.util.concurrent.CopyOnWriteArrayList;
30+
import java.util.concurrent.Executor;
2931
import java.util.concurrent.ScheduledFuture;
3032
import java.util.concurrent.TimeUnit;
3133
import java.util.concurrent.atomic.AtomicInteger;
@@ -38,6 +40,7 @@
3840

3941
import org.springframework.beans.factory.DisposableBean;
4042
import org.springframework.beans.factory.InitializingBean;
43+
import org.springframework.context.SmartLifecycle;
4144
import org.springframework.http.HttpStatus;
4245
import org.springframework.lang.Nullable;
4346
import org.springframework.scheduling.TaskScheduler;
@@ -46,7 +49,16 @@
4649
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
4750
import org.springframework.util.Assert;
4851
import org.springframework.util.StringUtils;
52+
import org.springframework.util.backoff.BackOff;
53+
import org.springframework.util.backoff.BackOffExecution;
54+
import org.springframework.util.backoff.ExponentialBackOff;
4955
import org.springframework.vault.VaultException;
56+
import org.springframework.vault.authentication.event.AuthenticationErrorEvent;
57+
import org.springframework.vault.authentication.event.AuthenticationErrorListener;
58+
import org.springframework.vault.authentication.event.AuthenticationEvent;
59+
import org.springframework.vault.authentication.event.AuthenticationListener;
60+
import org.springframework.vault.authentication.event.LoginTokenExpiredEvent;
61+
import org.springframework.vault.authentication.event.LoginTokenRenewalFailedEvent;
5062
import org.springframework.vault.client.VaultResponses;
5163
import org.springframework.vault.core.VaultOperations;
5264
import org.springframework.vault.core.lease.domain.Lease;
@@ -62,34 +74,23 @@
6274
/**
6375
* Event-based container to request secrets from Vault and renew the associated
6476
* {@link Lease}. Secrets can be rotated, depending on the requested
65-
* {@link RequestedSecret#getMode()}.
66-
*
67-
* Usage example:
68-
*
69-
* <pre>
77+
* {@link RequestedSecret#getMode()}. Usage example: <pre>
7078
* <code>
7179
* SecretLeaseContainer container = new SecretLeaseContainer(vaultOperations,
7280
* taskScheduler);
73-
*
7481
* RequestedSecret requestedSecret = container
7582
* .requestRotatingSecret("mysql/creds/my-role");
7683
* container.addLeaseListener(new LeaseListenerAdapter() {
7784
* &#64;Override
7885
* public void onLeaseEvent(SecretLeaseEvent secretLeaseEvent) {
79-
*
8086
* if (requestedSecret == secretLeaseEvent.getSource()) {
81-
*
8287
* if (secretLeaseEvent instanceof SecretLeaseCreatedEvent) {
83-
*
84-
* }
85-
*
88+
* }
8689
* if (secretLeaseEvent instanceof SecretLeaseExpiredEvent) {
87-
*
88-
* }
89-
* }
90-
* }
90+
* }
91+
* }
92+
* }
9193
* });
92-
*
9394
* container.afterPropertiesSet();
9495
* container.start(); // events are triggered after starting the container
9596
* </code> </pre>
@@ -120,7 +121,8 @@
120121
* @see LeaseEndpoints
121122
* @see LeaseStrategy
122123
*/
123-
public class SecretLeaseContainer extends SecretLeaseEventPublisher implements InitializingBean, DisposableBean {
124+
public class SecretLeaseContainer extends SecretLeaseEventPublisher
125+
implements InitializingBean, DisposableBean, SmartLifecycle {
124126

125127
private static final AtomicIntegerFieldUpdater<SecretLeaseContainer> UPDATER = AtomicIntegerFieldUpdater
126128
.newUpdater(SecretLeaseContainer.class, "status");
@@ -136,6 +138,10 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I
136138
@SuppressWarnings("FieldMayBeFinal") // allow setting via reflection.
137139
private static Log logger = LogFactory.getLog(SecretLeaseContainer.class);
138140

141+
private final Clock clock = Clock.systemDefaultZone();
142+
143+
private final LeaseAuthenticationEventListener authenticationListener = new LeaseAuthenticationEventListener();
144+
139145
private final List<RequestedSecret> requestedSecrets = new CopyOnWriteArrayList<>();
140146

141147
private final Map<RequestedSecret, LeaseRenewalScheduler> renewals = new ConcurrentHashMap<>();
@@ -189,13 +195,31 @@ public SecretLeaseContainer(VaultOperations operations, TaskScheduler taskSchedu
189195
setTaskScheduler(taskScheduler);
190196
}
191197

198+
/**
199+
* Returns the {@link AuthenticationListener} to listen for login token events.
200+
* @return the {@link AuthenticationListener} to listen for login token events.
201+
* @since 3.1
202+
*/
203+
public AuthenticationListener getAuthenticationListener() {
204+
return this.authenticationListener;
205+
}
206+
207+
/**
208+
* Returns the {@link AuthenticationListener} to listen for login token error events.
209+
* @return the {@link AuthenticationListener} to listen for login token error events
210+
* @since 3.1
211+
*/
212+
public AuthenticationErrorListener getAuthenticationErrorListener() {
213+
return this.authenticationListener;
214+
}
215+
192216
/**
193217
* Set the {@link LeaseEndpoints} to delegate renewal/revocation calls to.
194218
* {@link LeaseEndpoints} encapsulates differences between Vault versions that affect
195219
* the location of renewal/revocation endpoints.
196220
* @param leaseEndpoints must not be {@literal null}.
197-
* @since 2.1
198221
* @see LeaseEndpoints
222+
* @since 2.1
199223
*/
200224
public void setLeaseEndpoints(LeaseEndpoints leaseEndpoints) {
201225

@@ -336,6 +360,7 @@ public RequestedSecret addRequestedSecret(RequestedSecret requestedSecret) {
336360
* @see #afterPropertiesSet()
337361
* @see #stop()
338362
*/
363+
@Override
339364
public void start() {
340365

341366
Assert.state(this.initialized, "Container is not initialized");
@@ -409,6 +434,7 @@ private static boolean isRotatingGenericSecret(RequestedSecret requestedSecret,
409434
*
410435
* @see #start()
411436
*/
437+
@Override
412438
public void stop() {
413439

414440
if (UPDATER.compareAndSet(this, STATUS_STARTED, STATUS_INITIAL)) {
@@ -419,30 +445,41 @@ public void stop() {
419445
}
420446
}
421447

448+
@Override
449+
public boolean isRunning() {
450+
return UPDATER.get(this) == STATUS_STARTED;
451+
}
452+
453+
@Override
454+
public int getPhase() {
455+
return 200;
456+
}
457+
422458
@Override
423459
public void afterPropertiesSet() {
424460

425-
if (!this.initialized) {
461+
if (this.initialized) {
462+
return;
463+
}
426464

427-
super.afterPropertiesSet();
465+
super.afterPropertiesSet();
428466

429-
this.initialized = true;
467+
this.initialized = true;
430468

431-
if (this.taskScheduler == null) {
469+
if (this.taskScheduler == null) {
432470

433-
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
434-
scheduler.setDaemon(true);
435-
scheduler
436-
.setThreadNamePrefix(String.format("%s-%d-", getClass().getSimpleName(), poolId.incrementAndGet()));
437-
scheduler.afterPropertiesSet();
471+
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
472+
scheduler.setDaemon(true);
473+
scheduler
474+
.setThreadNamePrefix(String.format("%s-%d-", getClass().getSimpleName(), poolId.incrementAndGet()));
475+
scheduler.afterPropertiesSet();
438476

439-
this.taskScheduler = scheduler;
440-
this.manageTaskScheduler = true;
441-
}
477+
this.taskScheduler = scheduler;
478+
this.manageTaskScheduler = true;
479+
}
442480

443-
for (RequestedSecret requestedSecret : this.requestedSecrets) {
444-
this.renewals.put(requestedSecret, new LeaseRenewalScheduler(this.taskScheduler));
445-
}
481+
for (RequestedSecret requestedSecret : this.requestedSecrets) {
482+
this.renewals.put(requestedSecret, new LeaseRenewalScheduler(this.taskScheduler));
446483
}
447484
}
448485

@@ -472,6 +509,7 @@ public void destroy() throws Exception {
472509
doRevokeLease(entry.getKey(), lease);
473510
}
474511
}
512+
this.renewals.clear();
475513

476514
if (this.manageTaskScheduler) {
477515

@@ -484,6 +522,51 @@ public void destroy() throws Exception {
484522
}
485523
}
486524

525+
void restartSecrets() {
526+
527+
int status = this.status;
528+
if (status == STATUS_STARTED) {
529+
530+
logger.debug("Restarting all secrets after token expiry/rotation");
531+
532+
try {
533+
534+
Map<RequestedSecret, LeaseRenewalScheduler> previousLeases = new LinkedHashMap<>(this.renewals);
535+
this.renewals.clear();
536+
previousLeases.values().forEach(LeaseRenewalScheduler::disableScheduleRenewal);
537+
538+
for (RequestedSecret requestedSecret : this.requestedSecrets) {
539+
540+
LeaseRenewalScheduler renewalScheduler = new LeaseRenewalScheduler(this.taskScheduler);
541+
Lease previousLease = getPreviousLease(previousLeases, requestedSecret);
542+
543+
try {
544+
doStart(requestedSecret, renewalScheduler, (secrets, lease) -> {
545+
onSecretsRotated(requestedSecret, previousLease, lease, secrets.getRequiredData());
546+
}, () -> {
547+
});
548+
549+
}
550+
catch (Exception e) {
551+
onError(requestedSecret, previousLease, e);
552+
}
553+
}
554+
}
555+
catch (Exception e) {
556+
logger.error("Cannot restart secrets", e);
557+
}
558+
}
559+
}
560+
561+
private static Lease getPreviousLease(Map<RequestedSecret, LeaseRenewalScheduler> previousLeases,
562+
RequestedSecret requestedSecret) {
563+
564+
LeaseRenewalScheduler leaseRenewalScheduler = previousLeases.get(requestedSecret);
565+
Lease previousLease = leaseRenewalScheduler != null ? leaseRenewalScheduler.getLease() : null;
566+
567+
return previousLease == null ? Lease.none() : previousLease;
568+
}
569+
487570
/**
488571
* Renew a {@link RequestedSecret secret}.
489572
* @param secret the {@link RequestedSecret secret}' to renew.
@@ -712,6 +795,7 @@ private Lease doRenew(Lease lease) {
712795
* @param requestedSecret must not be {@literal null}.
713796
* @param lease must not be {@literal null}.
714797
*/
798+
@Override
715799
protected void onLeaseExpired(RequestedSecret requestedSecret, Lease lease) {
716800

717801
if (requestedSecret.getMode() == Mode.ROTATE) {
@@ -916,6 +1000,70 @@ private boolean isLeaseRotateOnly(Lease lease, RequestedSecret requestedSecret)
9161000

9171001
}
9181002

1003+
private class LeaseAuthenticationEventListener implements AuthenticationListener, AuthenticationErrorListener {
1004+
1005+
private final BackOff backOff = new ExponentialBackOff(500, 1.5);
1006+
1007+
private final AtomicReference<Timeout> timeout = new AtomicReference<>();
1008+
1009+
@Override
1010+
public void onAuthenticationError(AuthenticationErrorEvent authenticationEvent) {
1011+
if (authenticationEvent instanceof LoginTokenRenewalFailedEvent) {
1012+
logger.debug("LoginTokenRenewalFailedEvent received");
1013+
restartSecrets();
1014+
}
1015+
}
1016+
1017+
@Override
1018+
public void onAuthenticationEvent(AuthenticationEvent leaseEvent) {
1019+
if (leaseEvent instanceof LoginTokenExpiredEvent) {
1020+
logger.debug("LoginTokenExpiredEvent received");
1021+
restartSecrets();
1022+
}
1023+
}
1024+
1025+
/**
1026+
* Restart secrets after a changed token. Either the token was rotated or it has
1027+
* expired.
1028+
*/
1029+
private void restartSecrets() {
1030+
1031+
if (!isRunning()) {
1032+
logger.debug("Ignore token event as the container is not running");
1033+
}
1034+
1035+
Timeout timeout = this.timeout.get();
1036+
if (timeout != null && !timeout.isExpired(clock)) {
1037+
logger.debug("Backoff timeout not reached. Dropping event");
1038+
return;
1039+
}
1040+
1041+
Timeout executionToSet = new Timeout(backOff.start(), clock);
1042+
1043+
if (this.timeout.compareAndSet(timeout, executionToSet)) {
1044+
1045+
if (taskScheduler instanceof Executor e) {
1046+
e.execute(SecretLeaseContainer.this::restartSecrets);
1047+
}
1048+
else {
1049+
taskScheduler.schedule(SecretLeaseContainer.this::restartSecrets, Instant.now());
1050+
}
1051+
}
1052+
}
1053+
1054+
record Timeout(BackOffExecution execution, long timeout) {
1055+
1056+
public Timeout(BackOffExecution execution, Clock clock) {
1057+
this(execution, clock.millis() + execution.nextBackOff());
1058+
}
1059+
1060+
public boolean isExpired(Clock clock) {
1061+
return clock.millis() > timeout;
1062+
}
1063+
}
1064+
1065+
}
1066+
9191067
/**
9201068
* This one-shot trigger creates only one execution time to trigger an execution only
9211069
* once.

spring-vault-core/src/test/java/org/springframework/vault/annotation/VaultPropertySourceUnitTests.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ void shouldResolvePlaceholderForRenewablePropertySource() throws Exception {
157157
verify(leaseContainerMock).addLeaseListener(any());
158158
verify(leaseContainerMock).addErrorListener(any());
159159
verify(leaseContainerMock).addRequestedSecret(RequestedSecret.renewable("foo/renewable"));
160+
verify(leaseContainerMock).isAutoStartup();
160161
verifyNoMoreInteractions(leaseContainerMock);
161162
}
162163

0 commit comments

Comments
 (0)