diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java index d5fd0e6495..feedfd2e32 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.endpoint.invoker.cache; +import java.security.Principal; import java.time.Duration; import java.util.Map; import java.util.Objects; @@ -48,7 +49,7 @@ public class CachingOperationInvoker implements OperationInvoker { private final long timeToLive; - private final Map cachedResponses; + private final Map cachedResponses; /** * Create a new instance with the target {@link OperationInvoker} to use to compute @@ -78,19 +79,17 @@ public class CachingOperationInvoker implements OperationInvoker { } long accessTime = System.currentTimeMillis(); ApiVersion contextApiVersion = context.getApiVersion(); - CachedResponse cached = this.cachedResponses.get(contextApiVersion); + CacheKey cacheKey = new CacheKey(contextApiVersion, context.getSecurityContext().getPrincipal()); + CachedResponse cached = this.cachedResponses.get(cacheKey); if (cached == null || cached.isStale(accessTime, this.timeToLive)) { Object response = this.invoker.invoke(context); cached = createCachedResponse(response, accessTime); - this.cachedResponses.put(contextApiVersion, cached); + this.cachedResponses.put(cacheKey, cached); } return cached.getResponse(); } private boolean hasInput(InvocationContext context) { - if (context.getSecurityContext().getPrincipal() != null) { - return true; - } Map arguments = context.getArguments(); if (!ObjectUtils.isEmpty(arguments)) { return arguments.values().stream().anyMatch(Objects::nonNull); @@ -167,4 +166,52 @@ public class CachingOperationInvoker implements OperationInvoker { } + private static final class CacheKey { + + private final ApiVersion apiVersion; + + private final Principal principal; + + private CacheKey(ApiVersion apiVersion, Principal principal) { + this.principal = principal; + this.apiVersion = apiVersion; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + this.apiVersion.hashCode(); + result = prime * result + ((this.principal == null) ? 0 : this.principal.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + CacheKey other = (CacheKey) obj; + if (this.apiVersion != other.apiVersion) { + return false; + } + if (this.principal == null) { + if (other.principal != null) { + return false; + } + } + else if (!this.principal.equals(other.principal)) { + return false; + } + return true; + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java index 3e897ef625..bc6b74ed8e 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java @@ -64,6 +64,11 @@ class CachingOperationInvokerTests { assertCacheIsUsed(Collections.emptyMap()); } + @Test + void cacheInTtlWithPrincipal() { + assertCacheIsUsed(Collections.emptyMap(), mock(Principal.class)); + } + @Test void cacheInTtlWithNullParameters() { Map parameters = new HashMap<>(); @@ -97,9 +102,17 @@ class CachingOperationInvokerTests { } private void assertCacheIsUsed(Map parameters) { + assertCacheIsUsed(parameters, null); + } + + private void assertCacheIsUsed(Map parameters, Principal principal) { OperationInvoker target = mock(OperationInvoker.class); Object expected = new Object(); - InvocationContext context = new InvocationContext(mock(SecurityContext.class), parameters); + SecurityContext securityContext = mock(SecurityContext.class); + if (principal != null) { + given(securityContext.getPrincipal()).willReturn(principal); + } + InvocationContext context = new InvocationContext(securityContext, parameters); given(target.invoke(context)).willReturn(expected); CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL); Object response = invoker.invoke(context); @@ -126,20 +139,46 @@ class CachingOperationInvokerTests { } @Test - void targetAlwaysInvokedWithPrincipal() { + void targetAlwaysInvokedWithDifferentPrincipals() { OperationInvoker target = mock(OperationInvoker.class); Map parameters = new HashMap<>(); SecurityContext securityContext = mock(SecurityContext.class); - given(securityContext.getPrincipal()).willReturn(mock(Principal.class)); + given(securityContext.getPrincipal()).willReturn(mock(Principal.class), mock(Principal.class), + mock(Principal.class)); InvocationContext context = new InvocationContext(securityContext, parameters); - given(target.invoke(context)).willReturn(new Object()); + Object result1 = new Object(); + Object result2 = new Object(); + Object result3 = new Object(); + given(target.invoke(context)).willReturn(result1, result2, result3); CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL); - invoker.invoke(context); - invoker.invoke(context); - invoker.invoke(context); + assertThat(invoker.invoke(context)).isEqualTo(result1); + assertThat(invoker.invoke(context)).isEqualTo(result2); + assertThat(invoker.invoke(context)).isEqualTo(result3); verify(target, times(3)).invoke(context); } + @Test + void targetInvokedWhenCalledWithAndWithoutPrincipal() { + OperationInvoker target = mock(OperationInvoker.class); + Map parameters = new HashMap<>(); + SecurityContext anonymous = mock(SecurityContext.class); + SecurityContext authenticated = mock(SecurityContext.class); + given(authenticated.getPrincipal()).willReturn(mock(Principal.class)); + InvocationContext anonymousContext = new InvocationContext(anonymous, parameters); + Object anonymousResult = new Object(); + given(target.invoke(anonymousContext)).willReturn(anonymousResult); + InvocationContext authenticatedContext = new InvocationContext(authenticated, parameters); + Object authenticatedResult = new Object(); + given(target.invoke(authenticatedContext)).willReturn(authenticatedResult); + CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL); + assertThat(invoker.invoke(anonymousContext)).isEqualTo(anonymousResult); + assertThat(invoker.invoke(authenticatedContext)).isEqualTo(authenticatedResult); + assertThat(invoker.invoke(anonymousContext)).isEqualTo(anonymousResult); + assertThat(invoker.invoke(authenticatedContext)).isEqualTo(authenticatedResult); + verify(target, times(1)).invoke(anonymousContext); + verify(target, times(1)).invoke(authenticatedContext); + } + @Test void targetInvokedWhenCacheExpires() throws InterruptedException { OperationInvoker target = mock(OperationInvoker.class); diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc index 3103cd737d..664d8a9626 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/production-ready-features.adoc @@ -406,8 +406,6 @@ The following example sets the time-to-live of the `beans` endpoint's cache to 1 NOTE: The prefix `management.endpoint.` is used to uniquely identify the endpoint that is being configured. -NOTE: When making an authenticated HTTP request, the `Principal` is considered as input to the endpoint and, therefore, the response will not be cached. - [[production-ready-endpoints-hypermedia]]