From 3e4baf744ea24ffa12eafa91db3cb5d69ebbb41a Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 20 Feb 2018 07:34:26 +0000 Subject: [PATCH] Use role-based security to show details in the health endpoint Closes gh-11869 --- .../health/HealthEndpointProperties.java | 21 ++++- ...althEndpointWebExtensionConfiguration.java | 20 +++-- ...loudFoundryWebEndpointDiscovererTests.java | 5 +- .../HealthEndpointWebExtensionTests.java | 83 ++++++++++++++--- ...activeHealthEndpointWebExtensionTests.java | 89 +++++++++++++++--- .../actuate/endpoint/InvocationContext.java | 15 ++-- .../actuate/endpoint/SecurityContext.java | 43 +++++++++ .../reflect/ReflectiveOperationInvoker.java | 11 ++- .../cache/CachingOperationInvoker.java | 2 +- .../actuate/endpoint/jmx/EndpointMBean.java | 19 +++- .../jersey/JerseyEndpointResourceFactory.java | 24 ++++- ...AbstractWebFluxEndpointHandlerMapping.java | 89 ++++++++++++++---- .../AbstractWebMvcEndpointHandlerMapping.java | 24 ++++- .../health/HealthEndpointWebExtension.java | 28 ++---- .../HealthWebEndpointResponseMapper.java | 90 +++++++++++++++++++ .../ReactiveHealthEndpointWebExtension.java | 31 +++---- .../boot/actuate/health/ShowDetails.java | 4 +- .../DiscoveredOperationsFactoryTests.java | 8 +- .../ReflectiveOperationInvokerTests.java | 18 ++-- .../cache/CachingOperationInvokerTests.java | 10 ++- .../AbstractWebEndpointIntegrationTests.java | 90 +++++++++++++++++++ .../JerseyWebEndpointIntegrationTests.java | 43 ++++----- .../WebFluxEndpointIntegrationTests.java | 36 ++------ .../MvcWebEndpointIntegrationTests.java | 42 ++++----- .../HealthEndpointWebIntegrationTests.java | 6 +- .../appendix-application-properties.adoc | 3 +- .../asciidoc/production-ready-features.adoc | 10 ++- 27 files changed, 661 insertions(+), 203 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SecurityContext.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointProperties.java index a5b5f9e9dd8..721620562e4 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointProperties.java @@ -16,6 +16,9 @@ package org.springframework.boot.actuate.autoconfigure.health; +import java.util.HashSet; +import java.util.Set; + import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.actuate.health.ShowDetails; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -29,9 +32,15 @@ import org.springframework.boot.context.properties.ConfigurationProperties; public class HealthEndpointProperties { /** - * Whether to show full health details. + * When to show full health details. */ - private ShowDetails showDetails = ShowDetails.WHEN_AUTHENTICATED; + private ShowDetails showDetails = ShowDetails.WHEN_AUTHORIZED; + + /** + * Roles used to determine whether or not a user is authorized to be shown details. + * When empty, all authenticated users are authorized. + */ + private Set roles = new HashSet<>(); public ShowDetails getShowDetails() { return this.showDetails; @@ -41,4 +50,12 @@ public class HealthEndpointProperties { this.showDetails = showDetails; } + public Set getRoles() { + return this.roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java index f5793a1380d..25b0a602c00 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionConfiguration.java @@ -27,6 +27,7 @@ import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.actuate.health.HealthEndpointWebExtension; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.actuate.health.HealthStatusHttpMapper; +import org.springframework.boot.actuate.health.HealthWebEndpointResponseMapper; import org.springframework.boot.actuate.health.OrderedHealthAggregator; import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension; import org.springframework.boot.actuate.health.ReactiveHealthIndicator; @@ -59,6 +60,15 @@ class HealthEndpointWebExtensionConfiguration { return statusHttpMapper; } + @Bean + @ConditionalOnMissingBean + public HealthWebEndpointResponseMapper healthWebEndpointResponseMapper( + HealthStatusHttpMapper statusHttpMapper, + HealthEndpointProperties properties) { + return new HealthWebEndpointResponseMapper(statusHttpMapper, + properties.getShowDetails(), properties.getRoles()); + } + @Configuration @ConditionalOnWebApplication(type = Type.REACTIVE) static class ReactiveWebHealthConfiguration { @@ -81,10 +91,9 @@ class HealthEndpointWebExtensionConfiguration { @ConditionalOnEnabledEndpoint @ConditionalOnBean(HealthEndpoint.class) public ReactiveHealthEndpointWebExtension reactiveHealthEndpointWebExtension( - HealthStatusHttpMapper healthStatusHttpMapper, - HealthEndpointProperties properties) { + HealthWebEndpointResponseMapper responseMapper) { return new ReactiveHealthEndpointWebExtension(this.reactiveHealthIndicator, - healthStatusHttpMapper, properties.getShowDetails()); + responseMapper); } } @@ -99,11 +108,10 @@ class HealthEndpointWebExtensionConfiguration { @ConditionalOnBean(HealthEndpoint.class) public HealthEndpointWebExtension healthEndpointWebExtension( ApplicationContext applicationContext, - HealthStatusHttpMapper healthStatusHttpMapper, - HealthEndpointProperties properties) { + HealthWebEndpointResponseMapper responseMapper) { return new HealthEndpointWebExtension( HealthIndicatorBeansComposite.get(applicationContext), - healthStatusHttpMapper, properties.getShowDetails()); + responseMapper); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java index 0cafd3ee590..31acdc80344 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java @@ -24,6 +24,7 @@ import java.util.function.Function; import org.junit.Test; import org.springframework.boot.actuate.endpoint.InvocationContext; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper; @@ -58,8 +59,8 @@ public class CloudFoundryWebEndpointDiscovererTests { for (ExposableWebEndpoint endpoint : endpoints) { if (endpoint.getId().equals("health")) { WebOperation operation = endpoint.getOperations().iterator().next(); - assertThat(operation - .invoke(new InvocationContext(null, Collections.emptyMap()))) + assertThat(operation.invoke(new InvocationContext( + mock(SecurityContext.class), Collections.emptyMap()))) .isEqualTo("cf"); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java index ded4a61d325..a9872a071ca 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java @@ -17,17 +17,19 @@ package org.springframework.boot.actuate.autoconfigure.health; import java.security.Principal; -import java.util.Map; import org.junit.Test; +import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthEndpointWebExtension; -import org.springframework.boot.actuate.health.HealthStatusHttpMapper; +import org.springframework.boot.actuate.health.HealthWebEndpointResponseMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; /** @@ -63,12 +65,17 @@ public class HealthEndpointWebExtensionTests { .withPropertyValues("management.health.status.http-mapping.CUSTOM=500") .run((context) -> { Object extension = context.getBean(HealthEndpointWebExtension.class); - HealthStatusHttpMapper mapper = (HealthStatusHttpMapper) ReflectionTestUtils - .getField(extension, "statusHttpMapper"); - Map statusMappings = mapper.getStatusMapping(); - assertThat(statusMappings).containsEntry("DOWN", 503); - assertThat(statusMappings).containsEntry("OUT_OF_SERVICE", 503); - assertThat(statusMappings).containsEntry("CUSTOM", 500); + HealthWebEndpointResponseMapper responseMapper = (HealthWebEndpointResponseMapper) ReflectionTestUtils + .getField(extension, "responseMapper"); + Class securityContext = SecurityContext.class; + assertThat(responseMapper + .map(Health.down().build(), mock(securityContext)) + .getStatus()).isEqualTo(503); + assertThat(responseMapper.map(Health.status("OUT_OF_SERVICE").build(), + mock(securityContext)).getStatus()).isEqualTo(503); + assertThat(responseMapper + .map(Health.status("CUSTOM").build(), mock(securityContext)) + .getStatus()).isEqualTo(500); }); } @@ -77,7 +84,8 @@ public class HealthEndpointWebExtensionTests { this.contextRunner.run((context) -> { HealthEndpointWebExtension extension = context .getBean(HealthEndpointWebExtension.class); - assertThat(extension.getHealth(null).getBody().getDetails()).isEmpty(); + assertThat(extension.getHealth(mock(SecurityContext.class)).getBody() + .getDetails()).isEmpty(); }); } @@ -86,7 +94,9 @@ public class HealthEndpointWebExtensionTests { this.contextRunner.run((context) -> { HealthEndpointWebExtension extension = context .getBean(HealthEndpointWebExtension.class); - assertThat(extension.getHealth(mock(Principal.class)).getBody().getDetails()) + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()).willReturn(mock(Principal.class)); + assertThat(extension.getHealth(securityContext).getBody().getDetails()) .isNotEmpty(); }); } @@ -110,9 +120,60 @@ public class HealthEndpointWebExtensionTests { .run((context) -> { HealthEndpointWebExtension extension = context .getBean(HealthEndpointWebExtension.class); - assertThat(extension.getHealth(mock(Principal.class)).getBody() + assertThat(extension.getHealth(mock(SecurityContext.class)).getBody() .getDetails()).isEmpty(); }); } + @Test + public void detailsCanBeHiddenFromUnauthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(false); + assertThat( + extension.getHealth(securityContext).getBody().getDetails()) + .isEmpty(); + }); + } + + @Test + public void detailsCanBeShownToAuthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(true); + assertThat( + extension.getHealth(securityContext).getBody().getDetails()) + .isNotEmpty(); + }); + } + + @Test + public void roleCanBeCustomized() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ADMIN").run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ADMIN")).willReturn(true); + assertThat( + extension.getHealth(securityContext).getBody().getDetails()) + .isNotEmpty(); + }); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/ReactiveHealthEndpointWebExtensionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/ReactiveHealthEndpointWebExtensionTests.java index 3cecad50ea2..6621b55ee8f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/ReactiveHealthEndpointWebExtensionTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/ReactiveHealthEndpointWebExtensionTests.java @@ -17,15 +17,15 @@ package org.springframework.boot.actuate.autoconfigure.health; import java.security.Principal; -import java.util.Map; import org.junit.Test; import reactor.core.publisher.Mono; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.actuate.health.HealthIndicator; -import org.springframework.boot.actuate.health.HealthStatusHttpMapper; +import org.springframework.boot.actuate.health.HealthWebEndpointResponseMapper; import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension; import org.springframework.boot.actuate.health.ReactiveHealthIndicator; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; @@ -34,6 +34,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; /** @@ -69,12 +70,17 @@ public class ReactiveHealthEndpointWebExtensionTests { .run((context) -> { Object extension = context .getBean(ReactiveHealthEndpointWebExtension.class); - HealthStatusHttpMapper mapper = (HealthStatusHttpMapper) ReflectionTestUtils - .getField(extension, "statusHttpMapper"); - Map statusMappings = mapper.getStatusMapping(); - assertThat(statusMappings).containsEntry("DOWN", 503); - assertThat(statusMappings).containsEntry("OUT_OF_SERVICE", 503); - assertThat(statusMappings).containsEntry("CUSTOM", 500); + HealthWebEndpointResponseMapper responseMapper = (HealthWebEndpointResponseMapper) ReflectionTestUtils + .getField(extension, "responseMapper"); + Class securityContext = SecurityContext.class; + assertThat(responseMapper + .map(Health.down().build(), mock(securityContext)) + .getStatus()).isEqualTo(503); + assertThat(responseMapper.map(Health.status("OUT_OF_SERVICE").build(), + mock(securityContext)).getStatus()).isEqualTo(503); + assertThat(responseMapper + .map(Health.status("CUSTOM").build(), mock(securityContext)) + .getStatus()).isEqualTo(500); }); } @@ -86,8 +92,11 @@ public class ReactiveHealthEndpointWebExtensionTests { ReactiveHealthEndpointWebExtension extension = context .getBean(ReactiveHealthEndpointWebExtension.class); Health endpointHealth = endpoint.health(); - Health extensionHealth = extension.health(mock(Principal.class)) - .block().getBody(); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + Health extensionHealth = extension.health(securityContext).block() + .getBody(); assertThat(endpointHealth.getDetails()) .containsOnlyKeys("application", "first", "second"); assertThat(extensionHealth.getDetails()) @@ -100,7 +109,8 @@ public class ReactiveHealthEndpointWebExtensionTests { this.contextRunner.run((context) -> { ReactiveHealthEndpointWebExtension extension = context .getBean(ReactiveHealthEndpointWebExtension.class); - assertThat(extension.health(null).block().getBody().getDetails()).isEmpty(); + assertThat(extension.health(mock(SecurityContext.class)).block().getBody() + .getDetails()).isEmpty(); }); } @@ -109,8 +119,10 @@ public class ReactiveHealthEndpointWebExtensionTests { this.contextRunner.run((context) -> { ReactiveHealthEndpointWebExtension extension = context .getBean(ReactiveHealthEndpointWebExtension.class); - assertThat(extension.health(mock(Principal.class)).block().getBody() - .getDetails()).isNotEmpty(); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()).willReturn(mock(Principal.class)); + assertThat(extension.health(securityContext).block().getBody().getDetails()) + .isNotEmpty(); }); } @@ -133,11 +145,60 @@ public class ReactiveHealthEndpointWebExtensionTests { .run((context) -> { ReactiveHealthEndpointWebExtension extension = context .getBean(ReactiveHealthEndpointWebExtension.class); - assertThat(extension.health(mock(Principal.class)).block().getBody() + SecurityContext securityContext = mock(SecurityContext.class); + assertThat(extension.health(securityContext).block().getBody() .getDetails()).isEmpty(); }); } + @Test + public void detailsCanBeHiddenFromUnauthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + ReactiveHealthEndpointWebExtension extension = context + .getBean(ReactiveHealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(false); + assertThat(extension.health(securityContext).block().getBody() + .getDetails()).isEmpty(); + }); + } + + @Test + public void detailsCanBeShownToAuthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + ReactiveHealthEndpointWebExtension extension = context + .getBean(ReactiveHealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(true); + assertThat(extension.health(securityContext).block().getBody() + .getDetails()).isNotEmpty(); + }); + } + + @Test + public void roleCanBeCustomized() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ADMIN").run((context) -> { + ReactiveHealthEndpointWebExtension extension = context + .getBean(ReactiveHealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ADMIN")).willReturn(true); + assertThat(extension.health(securityContext).block().getBody() + .getDetails()).isNotEmpty(); + }); + } + @Configuration static class HealthIndicatorsConfiguration { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java index 19ae15e3e11..e16f61e6603 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java @@ -16,7 +16,6 @@ package org.springframework.boot.actuate.endpoint; -import java.security.Principal; import java.util.Map; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; @@ -30,24 +29,26 @@ import org.springframework.util.Assert; */ public class InvocationContext { - private final Principal principal; + private final SecurityContext securityContext; private final Map arguments; /** * Creates a new context for an operation being invoked by the given {@code principal} * with the given available {@code arguments}. - * @param principal the principal invoking the operation. May be {@code null} + * @param securityContext the current security context. Never {@code null} * @param arguments the arguments available to the operation. Never {@code null} */ - public InvocationContext(Principal principal, Map arguments) { + public InvocationContext(SecurityContext securityContext, + Map arguments) { + Assert.notNull(securityContext, "SecurityContext must not be null"); Assert.notNull(arguments, "Arguments must not be null"); - this.principal = principal; + this.securityContext = securityContext; this.arguments = arguments; } - public Principal getPrincipal() { - return this.principal; + public SecurityContext getSecurityContext() { + return this.securityContext; } public Map getArguments() { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SecurityContext.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SecurityContext.java new file mode 100644 index 00000000000..9838e177941 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SecurityContext.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2018 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.boot.actuate.endpoint; + +import java.security.Principal; + +/** + * Security context in which an endpoint is being invoked. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public interface SecurityContext { + + /** + * Return the currently authenticated {@link Principal} or {@code null}. + * @return the principal or {@code null} + */ + Principal getPrincipal(); + + /** + * Returns {@code true} if the currently authenticated user is in the given + * {@code role}, or false otherwise. + * @param role name of the role + * @return {@code true} if the user is in the given role + */ + boolean isUserInRole(String role); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java index de2885dbca9..301276c7e0c 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java @@ -22,6 +22,7 @@ import java.util.Set; import java.util.stream.Collectors; import org.springframework.boot.actuate.endpoint.InvocationContext; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.invoke.MissingParametersException; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.invoke.OperationParameter; @@ -89,7 +90,10 @@ public class ReflectiveOperationInvoker implements OperationInvoker { return false; } if (Principal.class.equals(parameter.getType())) { - return context.getPrincipal() == null; + return context.getSecurityContext().getPrincipal() == null; + } + if (SecurityContext.class.equals(parameter.getType())) { + return false; } return context.getArguments().get(parameter.getName()) == null; } @@ -102,7 +106,10 @@ public class ReflectiveOperationInvoker implements OperationInvoker { private Object resolveArgument(OperationParameter parameter, InvocationContext context) { if (Principal.class.equals(parameter.getType())) { - return context.getPrincipal(); + return context.getSecurityContext().getPrincipal(); + } + if (SecurityContext.class.equals(parameter.getType())) { + return context.getSecurityContext(); } Object value = context.getArguments().get(parameter.getName()); return this.parameterValueMapper.mapParameterValue(parameter, value); 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 217548ffac0..3e4480b2ac4 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 @@ -75,7 +75,7 @@ public class CachingOperationInvoker implements OperationInvoker { } private boolean hasInput(InvocationContext context) { - if (context.getPrincipal() != null) { + if (context.getSecurityContext().getPrincipal() != null) { return true; } Map arguments = context.getArguments(); diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBean.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBean.java index 6d59cd9e1e0..f669ab8372c 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBean.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/EndpointMBean.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.endpoint.jmx; +import java.security.Principal; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -33,6 +34,7 @@ import reactor.core.publisher.Mono; import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException; import org.springframework.boot.actuate.endpoint.InvocationContext; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -97,7 +99,8 @@ public class EndpointMBean implements DynamicMBean { String[] parameterNames = operation.getParameters().stream() .map(JmxOperationParameter::getName).toArray(String[]::new); Map arguments = getArguments(parameterNames, params); - Object result = operation.invoke(new InvocationContext(null, arguments)); + Object result = operation + .invoke(new InvocationContext(new JmxSecurityContext(), arguments)); if (REACTOR_PRESENT) { result = ReactiveHandler.handle(result); } @@ -149,4 +152,18 @@ public class EndpointMBean implements DynamicMBean { } + private static final class JmxSecurityContext implements SecurityContext { + + @Override + public Principal getPrincipal() { + return null; + } + + @Override + public boolean isUserInRole(String role) { + return false; + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java index 292afc7608e..2cc6d9ef17f 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java @@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.web.jersey; import java.io.IOException; import java.io.InputStream; +import java.security.Principal; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -40,6 +41,7 @@ import reactor.core.publisher.Mono; import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException; import org.springframework.boot.actuate.endpoint.InvocationContext; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -150,7 +152,7 @@ public class JerseyEndpointResourceFactory { arguments.putAll(extractQueryParameters(data)); try { Object response = this.operation.invoke(new InvocationContext( - data.getSecurityContext().getUserPrincipal(), arguments)); + new JerseySecurityContext(data.getSecurityContext()), arguments)); return convertToJaxRsResponse(response, data.getRequest().getMethod()); } catch (InvalidEndpointRequestException ex) { @@ -275,4 +277,24 @@ public class JerseyEndpointResourceFactory { } + private static final class JerseySecurityContext implements SecurityContext { + + private final javax.ws.rs.core.SecurityContext securityContext; + + private JerseySecurityContext(javax.ws.rs.core.SecurityContext securityContext) { + this.securityContext = securityContext; + } + + @Override + public Principal getPrincipal() { + return this.securityContext.getUserPrincipal(); + } + + @Override + public boolean isUserInRole(String role) { + return this.securityContext.isUserInRole(role); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java index d12e585c81a..ab1a192e5e3 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java @@ -21,6 +21,7 @@ import java.security.Principal; import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; +import java.util.function.Supplier; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; @@ -30,6 +31,7 @@ import reactor.core.scheduler.Schedulers; import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException; import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.OperationType; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -40,6 +42,10 @@ import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicat import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestBody; @@ -256,33 +262,55 @@ public abstract class AbstractWebFluxEndpointHandlerMapping private static final class ReactiveWebOperationAdapter implements ReactiveWebOperation { - private static final Principal NO_PRINCIPAL = new Principal() { - - @Override - public String getName() { - throw new UnsupportedOperationException(); - } - - }; - private final OperationInvoker invoker; + private final Supplier> securityContextSupplier; + private ReactiveWebOperationAdapter(OperationInvoker invoker) { this.invoker = invoker; + if (ClassUtils.isPresent( + "org.springframework.security.core.context.ReactiveSecurityContextHolder", + getClass().getClassLoader())) { + this.securityContextSupplier = this::springSecurityContext; + } + else { + this.securityContextSupplier = this::emptySecurityContext; + } + } + + public Mono springSecurityContext() { + return ReactiveSecurityContextHolder.getContext() + .map((securityContext) -> new ReactiveSecurityContext( + securityContext.getAuthentication())) + .switchIfEmpty(Mono.just(new ReactiveSecurityContext(null))); + } + + public Mono emptySecurityContext() { + return Mono.just(new SecurityContext() { + + @Override + public Principal getPrincipal() { + return null; + } + + @Override + public boolean isUserInRole(String role) { + return false; + } + + }); } @Override public Mono> handle(ServerWebExchange exchange, Map body) { - return exchange.getPrincipal().defaultIfEmpty(NO_PRINCIPAL) - .flatMap((principal) -> { - Map arguments = getArguments(exchange, body); - return handleResult( - (Publisher) this.invoker.invoke(new InvocationContext( - principal == NO_PRINCIPAL ? null : principal, - arguments)), - exchange.getRequest().getMethod()); - }); + Map arguments = getArguments(exchange, body); + return this.securityContextSupplier.get() + .map((securityContext) -> new InvocationContext(securityContext, + arguments)) + .flatMap((invocationContext) -> handleResult( + (Publisher) this.invoker.invoke(invocationContext), + exchange.getRequest().getMethod())); } private Map getArguments(ServerWebExchange exchange, @@ -358,4 +386,29 @@ public abstract class AbstractWebFluxEndpointHandlerMapping } } + + private static final class ReactiveSecurityContext implements SecurityContext { + + private final Authentication authentication; + + ReactiveSecurityContext(Authentication authentication) { + this.authentication = authentication; + } + + @Override + public Principal getPrincipal() { + return this.authentication; + } + + @Override + public boolean isUserInRole(String role) { + if (this.authentication == null) { + return false; + } + return AuthorityReactiveAuthorizationManager.hasRole(role) + .check(Mono.just(this.authentication), null).block().isGranted(); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java index 6cd86276a67..dd0e7b60b59 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.endpoint.web.servlet; import java.lang.reflect.Method; +import java.security.Principal; import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashMap; @@ -29,6 +30,7 @@ import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException; import org.springframework.boot.actuate.endpoint.InvocationContext; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -243,7 +245,7 @@ public abstract class AbstractWebMvcEndpointHandlerMapping try { return handleResult( this.invoker.invoke(new InvocationContext( - request.getUserPrincipal(), arguments)), + new ServletSecurityContext(request), arguments)), HttpMethod.valueOf(request.getMethod())); } catch (InvalidEndpointRequestException ex) { @@ -312,4 +314,24 @@ public abstract class AbstractWebMvcEndpointHandlerMapping } + private static final class ServletSecurityContext implements SecurityContext { + + private final HttpServletRequest request; + + private ServletSecurityContext(HttpServletRequest request) { + this.request = request; + } + + @Override + public Principal getPrincipal() { + return this.request.getUserPrincipal(); + } + + @Override + public boolean isUserInRole(String role) { + return this.request.isUserInRole(role); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java index c134bdcac27..4eed4b4a8cc 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java @@ -16,12 +16,10 @@ package org.springframework.boot.actuate.health; -import java.security.Principal; - +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; -import org.springframework.lang.Nullable; /** * {@link EndpointWebExtension} for the {@link HealthEndpoint}. @@ -39,31 +37,23 @@ public class HealthEndpointWebExtension { private final HealthIndicator delegate; - private final HealthStatusHttpMapper statusHttpMapper; - - private final ShowDetails showDetails; + private final HealthWebEndpointResponseMapper responseMapper; public HealthEndpointWebExtension(HealthIndicator delegate, - HealthStatusHttpMapper statusHttpMapper, ShowDetails showDetails) { + HealthWebEndpointResponseMapper responseMapper) { this.delegate = delegate; - this.statusHttpMapper = statusHttpMapper; - this.showDetails = showDetails; + this.responseMapper = responseMapper; } @ReadOperation - public WebEndpointResponse getHealth(@Nullable Principal principal) { - return getHealth(principal, this.showDetails); + public WebEndpointResponse getHealth(SecurityContext securityContext) { + return this.responseMapper.map(this.delegate.health(), securityContext); } - public WebEndpointResponse getHealth(Principal principal, + public WebEndpointResponse getHealth(SecurityContext securityContext, ShowDetails showDetails) { - Health health = this.delegate.health(); - Integer status = this.statusHttpMapper.mapStatus(health.getStatus()); - if (showDetails == ShowDetails.NEVER - || (showDetails == ShowDetails.WHEN_AUTHENTICATED && principal == null)) { - health = Health.status(health.getStatus()).build(); - } - return new WebEndpointResponse<>(health, status); + return this.responseMapper.map(this.delegate.health(), securityContext, + showDetails); } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java new file mode 100644 index 00000000000..9031cdd9e74 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-2018 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.boot.actuate.health; + +import java.util.Set; + +import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; +import org.springframework.util.CollectionUtils; + +/** + * Maps a {@link Health} to a {@WebEndpointResponse}. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class HealthWebEndpointResponseMapper { + + private final HealthStatusHttpMapper statusHttpMapper; + + private final ShowDetails showDetails; + + private final Set authorizedRoles; + + public HealthWebEndpointResponseMapper(HealthStatusHttpMapper statusHttpMapper, + ShowDetails showDetails, Set authorizedRoles) { + this.statusHttpMapper = statusHttpMapper; + this.showDetails = showDetails; + this.authorizedRoles = authorizedRoles; + } + + /** + * Maps the given {@code health} to a {@link WebEndpointResponse}, honouring the + * mapper's default {@link ShowDetails} using the given {@code securityContext}. + * @param health the health to map + * @param securityContext the security context + * @return the mapped response + */ + public WebEndpointResponse map(Health health, + SecurityContext securityContext) { + return map(health, securityContext, this.showDetails); + } + + /** + * Maps the given {@code health} to a {@link WebEndpointResponse}, honouring the given + * {@showDetails} using the given {@code securityContext}. + * @param health the health to map + * @param securityContext the security context + * @param showDetails when to show details in the response + * @return the mapped response + */ + public WebEndpointResponse map(Health health, SecurityContext securityContext, + ShowDetails showDetails) { + Integer status = this.statusHttpMapper.mapStatus(health.getStatus()); + if (showDetails == ShowDetails.NEVER + || (showDetails == ShowDetails.WHEN_AUTHORIZED + && (securityContext.getPrincipal() == null + || !isUserInRole(securityContext)))) { + health = Health.status(health.getStatus()).build(); + } + return new WebEndpointResponse<>(health, status); + } + + private boolean isUserInRole(SecurityContext securityContext) { + if (CollectionUtils.isEmpty(this.authorizedRoles)) { + return true; + } + for (String role : this.authorizedRoles) { + if (securityContext.isUserInRole(role)) { + return true; + } + } + return false; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ReactiveHealthEndpointWebExtension.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ReactiveHealthEndpointWebExtension.java index 5901a178e27..4f53f49dde1 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ReactiveHealthEndpointWebExtension.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ReactiveHealthEndpointWebExtension.java @@ -16,14 +16,12 @@ package org.springframework.boot.actuate.health; -import java.security.Principal; - import reactor.core.publisher.Mono; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; -import org.springframework.lang.Nullable; /** * Reactive {@link EndpointWebExtension} for the {@link HealthEndpoint}. @@ -36,33 +34,24 @@ public class ReactiveHealthEndpointWebExtension { private final ReactiveHealthIndicator delegate; - private final HealthStatusHttpMapper statusHttpMapper; - - private final ShowDetails showDetails; + private final HealthWebEndpointResponseMapper responseMapper; public ReactiveHealthEndpointWebExtension(ReactiveHealthIndicator delegate, - HealthStatusHttpMapper statusHttpMapper, ShowDetails showDetails) { + HealthWebEndpointResponseMapper responseMapper) { this.delegate = delegate; - this.statusHttpMapper = statusHttpMapper; - this.showDetails = showDetails; + this.responseMapper = responseMapper; } @ReadOperation - public Mono> health(@Nullable Principal principal) { - return health(principal, this.showDetails); + public Mono> health(SecurityContext securityContext) { + return this.delegate.health() + .map((health) -> this.responseMapper.map(health, securityContext)); } - public Mono> health(Principal principal, + public Mono> health(SecurityContext securityContext, ShowDetails showDetails) { - return this.delegate.health().map((health) -> { - Integer status = this.statusHttpMapper.mapStatus(health.getStatus()); - if (showDetails == ShowDetails.NEVER - || (showDetails == ShowDetails.WHEN_AUTHENTICATED - && principal == null)) { - health = Health.status(health.getStatus()).build(); - } - return new WebEndpointResponse<>(health, status); - }); + return this.delegate.health().map((health) -> this.responseMapper.map(health, + securityContext, showDetails)); } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ShowDetails.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ShowDetails.java index b1a85520373..6c7d48610fb 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ShowDetails.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/ShowDetails.java @@ -31,9 +31,9 @@ public enum ShowDetails { NEVER, /** - * Show details in the response when accessed by an authenticated user. + * Show details in the response when accessed by an authorized user. */ - WHEN_AUTHENTICATED, + WHEN_AUTHORIZED, /** * Always show details in the response. diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredOperationsFactoryTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredOperationsFactoryTests.java index c5e08b8b12e..34e546dd67d 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredOperationsFactoryTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredOperationsFactoryTests.java @@ -27,6 +27,7 @@ import org.junit.Test; import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.OperationType; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoke.OperationParameters; @@ -34,6 +35,7 @@ import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.boot.actuate.endpoint.invoke.reflect.OperationMethod; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link DiscoveredOperationsFactory}. @@ -106,7 +108,8 @@ public class DiscoveredOperationsFactoryTests { TestOperation operation = getFirst( this.factory.createOperations("test", new ExampleWithParams())); Map params = Collections.singletonMap("name", 123); - Object result = operation.invoke(new InvocationContext(null, params)); + Object result = operation + .invoke(new InvocationContext(mock(SecurityContext.class), params)); assertThat(result).isEqualTo("123"); } @@ -116,7 +119,8 @@ public class DiscoveredOperationsFactoryTests { this.invokerAdvisors.add(advisor); TestOperation operation = getFirst( this.factory.createOperations("test", new ExampleRead())); - operation.invoke(new InvocationContext(null, Collections.emptyMap())); + operation.invoke(new InvocationContext(mock(SecurityContext.class), + Collections.emptyMap())); assertThat(advisor.getEndpointId()).isEqualTo("test"); assertThat(advisor.getOperationType()).isEqualTo(OperationType.READ); assertThat(advisor.getParameters()).isEmpty(); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java index b379b37ce23..9bb76d960af 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java @@ -25,12 +25,14 @@ import org.junit.rules.ExpectedException; import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.OperationType; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.invoke.MissingParametersException; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.lang.Nullable; import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link ReflectiveOperationInvoker}. @@ -84,8 +86,8 @@ public class ReflectiveOperationInvokerTests { public void invokeShouldInvokeMethod() { ReflectiveOperationInvoker invoker = new ReflectiveOperationInvoker(this.target, this.operationMethod, this.parameterValueMapper); - Object result = invoker.invoke( - new InvocationContext(null, Collections.singletonMap("name", "boot"))); + Object result = invoker.invoke(new InvocationContext(mock(SecurityContext.class), + Collections.singletonMap("name", "boot"))); assertThat(result).isEqualTo("toob"); } @@ -94,8 +96,8 @@ public class ReflectiveOperationInvokerTests { ReflectiveOperationInvoker invoker = new ReflectiveOperationInvoker(this.target, this.operationMethod, this.parameterValueMapper); this.thrown.expect(MissingParametersException.class); - invoker.invoke( - new InvocationContext(null, Collections.singletonMap("name", null))); + invoker.invoke(new InvocationContext(mock(SecurityContext.class), + Collections.singletonMap("name", null))); } @Test @@ -104,8 +106,8 @@ public class ReflectiveOperationInvokerTests { Example.class, "reverseNullable", String.class), OperationType.READ); ReflectiveOperationInvoker invoker = new ReflectiveOperationInvoker(this.target, operationMethod, this.parameterValueMapper); - Object result = invoker.invoke( - new InvocationContext(null, Collections.singletonMap("name", null))); + Object result = invoker.invoke(new InvocationContext(mock(SecurityContext.class), + Collections.singletonMap("name", null))); assertThat(result).isEqualTo("llun"); } @@ -113,8 +115,8 @@ public class ReflectiveOperationInvokerTests { public void invokeShouldResolveParameters() { ReflectiveOperationInvoker invoker = new ReflectiveOperationInvoker(this.target, this.operationMethod, this.parameterValueMapper); - Object result = invoker.invoke( - new InvocationContext(null, Collections.singletonMap("name", 1234))); + Object result = invoker.invoke(new InvocationContext(mock(SecurityContext.class), + Collections.singletonMap("name", 1234))); assertThat(result).isEqualTo("4321"); } 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 17f8c36c08b..905776a7d93 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 @@ -25,6 +25,7 @@ import org.junit.Test; import org.junit.rules.ExpectedException; import org.springframework.boot.actuate.endpoint.InvocationContext; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import static org.assertj.core.api.Assertions.assertThat; @@ -67,7 +68,8 @@ public class CachingOperationInvokerTests { private void assertCacheIsUsed(Map parameters) { OperationInvoker target = mock(OperationInvoker.class); Object expected = new Object(); - InvocationContext context = new InvocationContext(null, parameters); + InvocationContext context = new InvocationContext(mock(SecurityContext.class), + parameters); given(target.invoke(context)).willReturn(expected); CachingOperationInvoker invoker = new CachingOperationInvoker(target, 500L); Object response = invoker.invoke(context); @@ -84,7 +86,8 @@ public class CachingOperationInvokerTests { Map parameters = new HashMap<>(); parameters.put("test", "value"); parameters.put("something", null); - InvocationContext context = new InvocationContext(null, parameters); + InvocationContext context = new InvocationContext(mock(SecurityContext.class), + parameters); given(target.invoke(context)).willReturn(new Object()); CachingOperationInvoker invoker = new CachingOperationInvoker(target, 500L); invoker.invoke(context); @@ -97,7 +100,8 @@ public class CachingOperationInvokerTests { public void targetInvokedWhenCacheExpires() throws InterruptedException { OperationInvoker target = mock(OperationInvoker.class); Map parameters = new HashMap<>(); - InvocationContext context = new InvocationContext(null, parameters); + InvocationContext context = new InvocationContext(mock(SecurityContext.class), + parameters); given(target.invoke(context)).willReturn(new Object()); CachingOperationInvoker invoker = new CachingOperationInvoker(target, 50L); invoker.invoke(context); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java index 349896f507e..a69aeaab0cb 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java @@ -30,6 +30,7 @@ import java.util.function.Supplier; import org.junit.Test; import reactor.core.publisher.Mono; +import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; @@ -361,6 +362,52 @@ public abstract class AbstractWebEndpointIntegrationTests client.get().uri("/securitycontext") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody(String.class).isEqualTo("None")); + } + + @Test + public void securityContextIsAvailableAndHasPrincipalWhenRequestHasPrincipal() { + load((context) -> { + this.authenticatedContextCustomizer.accept(context); + context.register(SecurityContextEndpointConfiguration.class); + }, (client) -> client.get().uri("/securitycontext") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus().isOk() + .expectBody(String.class).isEqualTo("Alice")); + } + + @Test + public void userInRoleReturnsFalseWhenRequestHasNoPrincipal() { + load(UserInRoleEndpointConfiguration.class, + (client) -> client.get().uri("/userinrole?role=ADMIN") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody(String.class).isEqualTo("ADMIN: false")); + } + + @Test + public void userInRoleReturnsFalseWhenUserIsNotInRole() { + load((context) -> { + this.authenticatedContextCustomizer.accept(context); + context.register(UserInRoleEndpointConfiguration.class); + }, (client) -> client.get().uri("/userinrole?role=ADMIN") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus().isOk() + .expectBody(String.class).isEqualTo("ADMIN: false")); + } + + @Test + public void userInRoleReturnsTrueWhenUserIsInRole() { + load((context) -> { + this.authenticatedContextCustomizer.accept(context); + context.register(UserInRoleEndpointConfiguration.class); + }, (client) -> client.get().uri("/userinrole?role=ACTUATOR") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus().isOk() + .expectBody(String.class).isEqualTo("ACTUATOR: true")); + } + protected abstract int getPort(T context); protected void validateErrorBody(WebTestClient.BodyContentSpec body, @@ -581,6 +628,28 @@ public abstract class AbstractWebEndpointIntegrationTests { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointIntegrationTests.java index 8124fe1d01f..8632b14ef7d 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointIntegrationTests.java @@ -16,7 +16,6 @@ package org.springframework.boot.actuate.endpoint.web.reactive; -import java.security.Principal; import java.util.Arrays; import org.junit.Test; @@ -40,10 +39,12 @@ import org.springframework.core.env.Environment; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.ServerWebExchangeDecorator; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; @@ -152,8 +153,12 @@ public class WebFluxEndpointIntegrationTests extends @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - return chain.filter( - new MockPrincipalServerWebExchangeDecorator(exchange)); + return chain.filter(exchange).subscriberContext( + ReactiveSecurityContextHolder.withAuthentication( + new UsernamePasswordAuthenticationToken("Alice", + "secret", + Arrays.asList(new SimpleGrantedAuthority( + "ROLE_ACTUATOR"))))); } }; @@ -161,27 +166,4 @@ public class WebFluxEndpointIntegrationTests extends } - private static class MockPrincipalServerWebExchangeDecorator - extends ServerWebExchangeDecorator { - - MockPrincipalServerWebExchangeDecorator(ServerWebExchange delegate) { - super(delegate); - } - - @Override - public Mono getPrincipal() { - return Mono.just(new MockPrincipal()); - } - - } - - private static class MockPrincipal implements Principal { - - @Override - public String getName() { - return "Alice"; - } - - } - } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/MvcWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/MvcWebEndpointIntegrationTests.java index 962afb984ec..4134ca75901 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/MvcWebEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/MvcWebEndpointIntegrationTests.java @@ -17,14 +17,12 @@ package org.springframework.boot.actuate.endpoint.web.servlet; import java.io.IOException; -import java.security.Principal; import java.util.Arrays; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; import org.junit.Test; @@ -48,6 +46,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestWrapper; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.filter.OncePerRequestFilter; @@ -145,7 +148,18 @@ public class MvcWebEndpointIntegrationTests extends protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - filterChain.doFilter(new MockPrincipalWrapper(request), response); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UsernamePasswordAuthenticationToken( + "Alice", "secret", + Arrays.asList(new SimpleGrantedAuthority("ROLE_ACTUATOR")))); + SecurityContextHolder.setContext(context); + try { + filterChain.doFilter(new SecurityContextHolderAwareRequestWrapper( + request, "ROLE_"), response); + } + finally { + SecurityContextHolder.clearContext(); + } } }; @@ -153,26 +167,4 @@ public class MvcWebEndpointIntegrationTests extends } - private static class MockPrincipalWrapper extends HttpServletRequestWrapper { - - MockPrincipalWrapper(HttpServletRequest request) { - super(request); - } - - @Override - public Principal getUserPrincipal() { - return new MockPrincipal(); - } - - } - - private static class MockPrincipal implements Principal { - - @Override - public String getName() { - return "Alice"; - } - - } - } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointWebIntegrationTests.java index a5519402535..6f06c212c3b 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointWebIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointWebIntegrationTests.java @@ -16,6 +16,8 @@ package org.springframework.boot.actuate.health; +import java.util.Arrays; +import java.util.HashSet; import java.util.Map; import org.junit.Test; @@ -75,7 +77,9 @@ public class HealthEndpointWebIntegrationTests { return new HealthEndpointWebExtension( new CompositeHealthIndicatorFactory().createHealthIndicator( new OrderedHealthAggregator(), healthIndicators), - new HealthStatusHttpMapper(), ShowDetails.ALWAYS); + new HealthWebEndpointResponseMapper(new HealthStatusHttpMapper(), + ShowDetails.ALWAYS, + new HashSet<>(Arrays.asList("ACTUATOR")))); } @Bean diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc index 1789ee17ad0..1b6efe14930 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc @@ -1200,7 +1200,8 @@ content into your application. Rather, pick only the properties that you need. # HEALTH ENDPOINT ({sc-spring-boot-actuator}/health/HealthEndpoint.{sc-ext}[HealthEndpoint], {sc-spring-boot-actuator-autoconfigure}/health/HealthEndpointProperties.{sc-ext}[HealthEndpointProperties]) management.endpoint.health.cache.time-to-live=0ms # Maximum time that a response can be cached. management.endpoint.health.enabled= # Whether to enable the health endpoint. - management.endpoint.health.show-details=false # Whether to show full health details instead of just the status when exposed over a potentially insecure connection. + management.endpoint.health.roles= # Roles used to determine whether or not a user is authorized to be shown details. When empty, all authenticated users are authorized. + management.endpoint.health.show-details=when-authorized # When to show full health details. # HEAP DUMP ENDPOINT ({sc-spring-boot-actuator}/management/HeapDumpWebEndpoint.{sc-ext}[HeapDumpWebEndpoint]) management.endpoint.heapdump.cache.time-to-live=0ms # Maximum time that a response can be cached. diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc index 9eaff94ca7e..655735c1003 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc @@ -523,14 +523,18 @@ following values: |`never` |Details are never shown. -|`when-authenticated` -|Details are only shown to authenticated users. +|`when-authorized` +|Details are only shown to authorized users. Authorized roles can be configured using +`management.endpoint.health.roles`. |`always` |Details are shown to all users. |=== -The default value is `when-authenticated`. +The default value is `when-authorized`. A user is considered to be authorized when they +are in one or more of the endpoint's roles. If the endpoint has no configured roles +(the default) all authenticated users are considered to be authorized. The roles can +be configured using the `management.endpoint.health.roles` property. NOTE: If you have secured your application and wish to use `always`, your security configuration must permit access to the health endpoint for both authenticated and