Skip to content

Commit 0315724

Browse files
committed
Cache endpoint responses on a per-principal basis
Previously, any HTTP request to an endpoint that included a principal would bypass the cache. This prevented authenticated requests from making use of the cache and its configurable time-to-live. This commit updates the caching operation invoker to include the principal, if any, in its cache key. As a result, requests that include a principal will make use of the cache, potentially returning the result of a previous invocation of the same endpoint by the same principal. Closes gh-19538
1 parent ef9960c commit 0315724

File tree

3 files changed

+99
-15
lines changed

3 files changed

+99
-15
lines changed

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.boot.actuate.endpoint.invoker.cache;
1818

19+
import java.security.Principal;
1920
import java.time.Duration;
2021
import java.util.Map;
2122
import java.util.Objects;
@@ -48,7 +49,7 @@ public class CachingOperationInvoker implements OperationInvoker {
4849

4950
private final long timeToLive;
5051

51-
private final Map<ApiVersion, CachedResponse> cachedResponses;
52+
private final Map<CacheKey, CachedResponse> cachedResponses;
5253

5354
/**
5455
* Create a new instance with the target {@link OperationInvoker} to use to compute
@@ -78,19 +79,17 @@ public Object invoke(InvocationContext context) {
7879
}
7980
long accessTime = System.currentTimeMillis();
8081
ApiVersion contextApiVersion = context.getApiVersion();
81-
CachedResponse cached = this.cachedResponses.get(contextApiVersion);
82+
CacheKey cacheKey = new CacheKey(contextApiVersion, context.getSecurityContext().getPrincipal());
83+
CachedResponse cached = this.cachedResponses.get(cacheKey);
8284
if (cached == null || cached.isStale(accessTime, this.timeToLive)) {
8385
Object response = this.invoker.invoke(context);
8486
cached = createCachedResponse(response, accessTime);
85-
this.cachedResponses.put(contextApiVersion, cached);
87+
this.cachedResponses.put(cacheKey, cached);
8688
}
8789
return cached.getResponse();
8890
}
8991

9092
private boolean hasInput(InvocationContext context) {
91-
if (context.getSecurityContext().getPrincipal() != null) {
92-
return true;
93-
}
9493
Map<String, Object> arguments = context.getArguments();
9594
if (!ObjectUtils.isEmpty(arguments)) {
9695
return arguments.values().stream().anyMatch(Objects::nonNull);
@@ -167,4 +166,52 @@ private static Object applyCaching(Object response, long timeToLive) {
167166

168167
}
169168

169+
private static final class CacheKey {
170+
171+
private final ApiVersion apiVersion;
172+
173+
private final Principal principal;
174+
175+
private CacheKey(ApiVersion apiVersion, Principal principal) {
176+
this.principal = principal;
177+
this.apiVersion = apiVersion;
178+
}
179+
180+
@Override
181+
public int hashCode() {
182+
final int prime = 31;
183+
int result = 1;
184+
result = prime * result + this.apiVersion.hashCode();
185+
result = prime * result + ((this.principal == null) ? 0 : this.principal.hashCode());
186+
return result;
187+
}
188+
189+
@Override
190+
public boolean equals(Object obj) {
191+
if (this == obj) {
192+
return true;
193+
}
194+
if (obj == null) {
195+
return false;
196+
}
197+
if (getClass() != obj.getClass()) {
198+
return false;
199+
}
200+
CacheKey other = (CacheKey) obj;
201+
if (this.apiVersion != other.apiVersion) {
202+
return false;
203+
}
204+
if (this.principal == null) {
205+
if (other.principal != null) {
206+
return false;
207+
}
208+
}
209+
else if (!this.principal.equals(other.principal)) {
210+
return false;
211+
}
212+
return true;
213+
}
214+
215+
}
216+
170217
}

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ void cacheInTtlRangeWithNoParameter() {
6464
assertCacheIsUsed(Collections.emptyMap());
6565
}
6666

67+
@Test
68+
void cacheInTtlWithPrincipal() {
69+
assertCacheIsUsed(Collections.emptyMap(), mock(Principal.class));
70+
}
71+
6772
@Test
6873
void cacheInTtlWithNullParameters() {
6974
Map<String, Object> parameters = new HashMap<>();
@@ -97,9 +102,17 @@ void cacheInTtlWithFluxResponse() {
97102
}
98103

99104
private void assertCacheIsUsed(Map<String, Object> parameters) {
105+
assertCacheIsUsed(parameters, null);
106+
}
107+
108+
private void assertCacheIsUsed(Map<String, Object> parameters, Principal principal) {
100109
OperationInvoker target = mock(OperationInvoker.class);
101110
Object expected = new Object();
102-
InvocationContext context = new InvocationContext(mock(SecurityContext.class), parameters);
111+
SecurityContext securityContext = mock(SecurityContext.class);
112+
if (principal != null) {
113+
given(securityContext.getPrincipal()).willReturn(principal);
114+
}
115+
InvocationContext context = new InvocationContext(securityContext, parameters);
103116
given(target.invoke(context)).willReturn(expected);
104117
CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL);
105118
Object response = invoker.invoke(context);
@@ -126,20 +139,46 @@ void targetAlwaysInvokedWithParameters() {
126139
}
127140

128141
@Test
129-
void targetAlwaysInvokedWithPrincipal() {
142+
void targetAlwaysInvokedWithDifferentPrincipals() {
130143
OperationInvoker target = mock(OperationInvoker.class);
131144
Map<String, Object> parameters = new HashMap<>();
132145
SecurityContext securityContext = mock(SecurityContext.class);
133-
given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
146+
given(securityContext.getPrincipal()).willReturn(mock(Principal.class), mock(Principal.class),
147+
mock(Principal.class));
134148
InvocationContext context = new InvocationContext(securityContext, parameters);
135-
given(target.invoke(context)).willReturn(new Object());
149+
Object result1 = new Object();
150+
Object result2 = new Object();
151+
Object result3 = new Object();
152+
given(target.invoke(context)).willReturn(result1, result2, result3);
136153
CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL);
137-
invoker.invoke(context);
138-
invoker.invoke(context);
139-
invoker.invoke(context);
154+
assertThat(invoker.invoke(context)).isEqualTo(result1);
155+
assertThat(invoker.invoke(context)).isEqualTo(result2);
156+
assertThat(invoker.invoke(context)).isEqualTo(result3);
140157
verify(target, times(3)).invoke(context);
141158
}
142159

160+
@Test
161+
void targetInvokedWhenCalledWithAndWithoutPrincipal() {
162+
OperationInvoker target = mock(OperationInvoker.class);
163+
Map<String, Object> parameters = new HashMap<>();
164+
SecurityContext anonymous = mock(SecurityContext.class);
165+
SecurityContext authenticated = mock(SecurityContext.class);
166+
given(authenticated.getPrincipal()).willReturn(mock(Principal.class));
167+
InvocationContext anonymousContext = new InvocationContext(anonymous, parameters);
168+
Object anonymousResult = new Object();
169+
given(target.invoke(anonymousContext)).willReturn(anonymousResult);
170+
InvocationContext authenticatedContext = new InvocationContext(authenticated, parameters);
171+
Object authenticatedResult = new Object();
172+
given(target.invoke(authenticatedContext)).willReturn(authenticatedResult);
173+
CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL);
174+
assertThat(invoker.invoke(anonymousContext)).isEqualTo(anonymousResult);
175+
assertThat(invoker.invoke(authenticatedContext)).isEqualTo(authenticatedResult);
176+
assertThat(invoker.invoke(anonymousContext)).isEqualTo(anonymousResult);
177+
assertThat(invoker.invoke(authenticatedContext)).isEqualTo(authenticatedResult);
178+
verify(target, times(1)).invoke(anonymousContext);
179+
verify(target, times(1)).invoke(authenticatedContext);
180+
}
181+
143182
@Test
144183
void targetInvokedWhenCacheExpires() throws InterruptedException {
145184
OperationInvoker target = mock(OperationInvoker.class);

spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,6 @@ The following example sets the time-to-live of the `beans` endpoint's cache to 1
406406

407407
NOTE: The prefix `management.endpoint.<name>` is used to uniquely identify the endpoint that is being configured.
408408

409-
NOTE: When making an authenticated HTTP request, the `Principal` is considered as input to the endpoint and, therefore, the response will not be cached.
410-
411409

412410

413411
[[production-ready-endpoints-hypermedia]]

0 commit comments

Comments
 (0)