diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java index 1df7fa1cf0..07eef0f98c 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java @@ -54,7 +54,6 @@ import org.springframework.context.annotation.ConditionContext; import org.springframework.context.annotation.Conditional; import org.springframework.core.env.Environment; import org.springframework.core.type.AnnotatedTypeMetadata; -import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.cors.CorsConfiguration; @@ -162,7 +161,7 @@ public class EndpointWebMvcManagementContextConfiguration { @ConditionalOnEnabledEndpoint("health") public HealthMvcEndpoint healthMvcEndpoint(HealthEndpoint delegate) { HealthMvcEndpoint healthMvcEndpoint = new HealthMvcEndpoint(delegate, - isHealthSecure()); + this.managementServerProperties.getSecurity().isEnabled()); if (this.healthMvcEndpointProperties.getMapping() != null) { healthMvcEndpoint .addStatusMapping(this.healthMvcEndpointProperties.getMapping()); @@ -206,17 +205,6 @@ public class EndpointWebMvcManagementContextConfiguration { return new AuditEventsMvcEndpoint(auditEventRepository); } - private boolean isHealthSecure() { - return isSpringSecurityAvailable() - && this.managementServerProperties.getSecurity().isEnabled(); - } - - private boolean isSpringSecurityAvailable() { - return ClassUtils.isPresent( - "org.springframework.security.config.annotation.web.WebSecurityConfigurer", - getClass().getClassLoader()); - } - private static class LogFileCondition extends SpringBootCondition { @Override diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryHealthMvcEndpoint.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryHealthMvcEndpoint.java index 7d25eb2234..fc7ff44cd3 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryHealthMvcEndpoint.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryHealthMvcEndpoint.java @@ -16,7 +16,7 @@ package org.springframework.boot.actuate.cloudfoundry; -import java.security.Principal; +import javax.servlet.http.HttpServletRequest; import org.springframework.boot.actuate.endpoint.HealthEndpoint; import org.springframework.boot.actuate.endpoint.mvc.HealthMvcEndpoint; @@ -36,7 +36,7 @@ class CloudFoundryHealthMvcEndpoint extends HealthMvcEndpoint { } @Override - protected boolean exposeHealthDetails(Principal principal) { + protected boolean exposeHealthDetails(HttpServletRequest request) { return true; } diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpoint.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpoint.java index 53bd7eded7..bd71e0dc93 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpoint.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpoint.java @@ -16,12 +16,11 @@ package org.springframework.boot.actuate.endpoint.mvc; -import java.security.Principal; -import java.util.Arrays; import java.util.HashMap; -import java.util.List; import java.util.Map; +import javax.servlet.http.HttpServletRequest; + import org.springframework.boot.actuate.endpoint.HealthEndpoint; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.Status; @@ -33,10 +32,7 @@ import org.springframework.core.env.Environment; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; @@ -49,6 +45,7 @@ import org.springframework.web.bind.annotation.ResponseBody; * @author Andy Wilkinson * @author Phillip Webb * @author Eddú Meléndez + * @author Madhura Bhave * @since 1.1.0 */ @ConfigurationProperties(prefix = "endpoints.health") @@ -59,11 +56,7 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter statusMapping = new HashMap(); - private RelaxedPropertyResolver healthPropertyResolver; - - private RelaxedPropertyResolver endpointPropertyResolver; - - private RelaxedPropertyResolver roleResolver; + private RelaxedPropertyResolver securityPropertyResolver; private long lastAccess = 0; @@ -86,11 +79,7 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter(health, status); @@ -163,13 +152,13 @@ public class HealthMvcEndpoint extends AbstractEndpointMvcAdapter= getDelegate().getTimeToLive(); } - protected boolean exposeHealthDetails(Principal principal) { - return isSecure(principal) || isUnrestricted(); - } - - private boolean isSecure(Principal principal) { - if (principal == null || principal.getClass().getName().contains("Anonymous")) { - return false; + protected boolean exposeHealthDetails(HttpServletRequest request) { + if (!this.secure) { + return true; } - if (isSpringSecurityAuthentication(principal)) { - Authentication authentication = (Authentication) principal; - List roles = Arrays.asList(StringUtils - .trimArrayElements(StringUtils.commaDelimitedListToStringArray( - this.roleResolver.getProperty("roles", "ROLE_ACTUATOR")))); - for (GrantedAuthority authority : authentication.getAuthorities()) { - String name = authority.getAuthority(); - for (String role : roles) { - if (role.equals(name) || ("ROLE_" + role).equals(name)) { - return true; - } - } + String[] roles = StringUtils.commaDelimitedListToStringArray( + this.securityPropertyResolver.getProperty("roles", "ROLE_ACTUATOR")); + roles = StringUtils.trimArrayElements(roles); + for (String role : roles) { + if (request.isUserInRole(role) || request.isUserInRole("ROLE_" + role)) { + return true; } } return false; } - private boolean isSpringSecurityAuthentication(Principal principal) { - return ClassUtils.isPresent("org.springframework.security.core.Authentication", - null) && (principal instanceof Authentication); - } - - private boolean isUnrestricted() { - Boolean sensitive = this.healthPropertyResolver.getProperty("sensitive", - Boolean.class); - if (sensitive == null) { - sensitive = this.endpointPropertyResolver.getProperty("sensitive", - Boolean.class); - } - return !this.secure && !Boolean.TRUE.equals(sensitive); - } - } diff --git a/spring-boot-actuator/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-actuator/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 2fdd6907bd..8547f39240 100644 --- a/spring-boot-actuator/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-actuator/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -85,6 +85,18 @@ "type": "java.util.Map", "description": "Arbitrary properties to add to the info endpoint." }, + { + "name": "management.cloudfoundry.enabled", + "type": "java.lang.Boolean", + "description": "Enable extended Cloud Foundry actuator endpoints.", + "defaultValue": true + }, + { + "name": "management.cloudfoundry.skip-ssl-validation", + "type": "java.lang.Boolean", + "description": "Skip SSL verification for Cloud Foundry actuator endpoint security calls.", + "defaultValue": false + }, { "name": "management.health.cassandra.enabled", "type": "java.lang.Boolean", diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/HealthMvcEndpointAutoConfigurationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/HealthMvcEndpointAutoConfigurationTests.java index 2a1eed962c..34a5fdfee6 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/HealthMvcEndpointAutoConfigurationTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/HealthMvcEndpointAutoConfigurationTests.java @@ -32,6 +32,7 @@ import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; import org.springframework.boot.test.util.EnvironmentTestUtils; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockServletContext; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; @@ -60,8 +61,9 @@ public class HealthMvcEndpointAutoConfigurationTests { this.context.setServletContext(new MockServletContext()); this.context.register(TestConfiguration.class); this.context.refresh(); + MockHttpServletRequest request = new MockHttpServletRequest(); Health health = (Health) this.context.getBean(HealthMvcEndpoint.class) - .invoke(null); + .invoke(request); assertThat(health.getStatus()).isEqualTo(Status.UP); assertThat(health.getDetails().get("foo")).isNull(); } diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpointTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpointTests.java index 1a5595c42c..9f627a91eb 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpointTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HealthMvcEndpointTests.java @@ -18,20 +18,21 @@ package org.springframework.boot.actuate.endpoint.mvc; import java.util.Collections; +import javax.servlet.http.HttpServletRequest; + import org.junit.Before; import org.junit.Test; import org.springframework.boot.actuate.endpoint.HealthEndpoint; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.Status; -import org.springframework.boot.test.util.EnvironmentTestUtils; import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.PropertySource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.mock.env.MockEnvironment; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockServletContext; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -44,36 +45,36 @@ import static org.mockito.Mockito.mock; * @author Dave Syer * @author Andy Wilkinson * @author Eddú Meléndez + * @author Madhura Bhave */ public class HealthMvcEndpointTests { - private static final PropertySource NON_SENSITIVE = new MapPropertySource("test", - Collections.singletonMap("endpoints.health.sensitive", - "false")); - private static final PropertySource SECURITY_ROLES = new MapPropertySource("test", Collections.singletonMap("management.security.roles", "HERO, USER")); + private HttpServletRequest request = new MockHttpServletRequest(); + private HealthEndpoint endpoint = null; private HealthMvcEndpoint mvc = null; private MockEnvironment environment; - private UsernamePasswordAuthenticationToken user = createAuthenticationToken( + private HttpServletRequest user = createAuthenticationToken( "ROLE_USER"); - private UsernamePasswordAuthenticationToken actuator = createAuthenticationToken( + private HttpServletRequest actuator = createAuthenticationToken( "ROLE_ACTUATOR"); - private UsernamePasswordAuthenticationToken hero = createAuthenticationToken( + private HttpServletRequest hero = createAuthenticationToken( "ROLE_HERO"); - private UsernamePasswordAuthenticationToken createAuthenticationToken( - String authority) { - return new UsernamePasswordAuthenticationToken("user", "password", - AuthorityUtils.commaSeparatedStringToAuthorityList(authority)); + private HttpServletRequest createAuthenticationToken( + String role) { + MockServletContext servletContext = new MockServletContext(); + servletContext.declareRoles(role); + return new MockHttpServletRequest(servletContext); } @Before @@ -88,7 +89,7 @@ public class HealthMvcEndpointTests { @Test public void up() { given(this.endpoint.invoke()).willReturn(new Health.Builder().up().build()); - Object result = this.mvc.invoke(null); + Object result = this.mvc.invoke(this.request); assertThat(result instanceof Health).isTrue(); assertThat(((Health) result).getStatus() == Status.UP).isTrue(); } @@ -97,7 +98,7 @@ public class HealthMvcEndpointTests { @Test public void down() { given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build()); - Object result = this.mvc.invoke(null); + Object result = this.mvc.invoke(this.request); assertThat(result instanceof ResponseEntity).isTrue(); ResponseEntity response = (ResponseEntity) result; assertThat(response.getBody().getStatus() == Status.DOWN).isTrue(); @@ -111,7 +112,7 @@ public class HealthMvcEndpointTests { .willReturn(new Health.Builder().status("OK").build()); this.mvc.setStatusMapping( Collections.singletonMap("OK", HttpStatus.INTERNAL_SERVER_ERROR)); - Object result = this.mvc.invoke(null); + Object result = this.mvc.invoke(this.request); assertThat(result instanceof ResponseEntity).isTrue(); ResponseEntity response = (ResponseEntity) result; assertThat(response.getBody().getStatus().equals(new Status("OK"))).isTrue(); @@ -125,7 +126,7 @@ public class HealthMvcEndpointTests { .willReturn(new Health.Builder().outOfService().build()); this.mvc.setStatusMapping(Collections.singletonMap("out-of-service", HttpStatus.INTERNAL_SERVER_ERROR)); - Object result = this.mvc.invoke(null); + Object result = this.mvc.invoke(this.request); assertThat(result instanceof ResponseEntity).isTrue(); ResponseEntity response = (ResponseEntity) result; assertThat(response.getBody().getStatus().equals(Status.OUT_OF_SERVICE)).isTrue(); @@ -133,10 +134,9 @@ public class HealthMvcEndpointTests { } @Test - public void secureEvenWhenNotSensitive() { + public void presenceOfRightRoleShouldExposeDetails() { given(this.endpoint.invoke()) .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - given(this.endpoint.isSensitive()).willReturn(false); Object result = this.mvc.invoke(this.actuator); assertThat(result instanceof Health).isTrue(); assertThat(((Health) result).getStatus() == Status.UP).isTrue(); @@ -144,7 +144,18 @@ public class HealthMvcEndpointTests { } @Test - public void secureNonAdmin() { + public void managementSecurityDisabledShouldExposeDetails() throws Exception { + this.mvc = new HealthMvcEndpoint(this.endpoint, false); + given(this.endpoint.invoke()) + .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); + Object result = this.mvc.invoke(this.user); + assertThat(result instanceof Health).isTrue(); + assertThat(((Health) result).getStatus() == Status.UP).isTrue(); + assertThat(((Health) result).getDetails().get("foo")).isEqualTo("bar"); + } + + @Test + public void rightRoleNotPresentShouldNotExposeDetails() { given(this.endpoint.invoke()) .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); Object result = this.mvc.invoke(this.user); @@ -154,7 +165,7 @@ public class HealthMvcEndpointTests { } @Test - public void secureCustomRole() { + public void customRolePresentShouldExposeDetails() { this.environment.getPropertySources().addLast(SECURITY_ROLES); given(this.endpoint.invoke()) .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); @@ -165,7 +176,7 @@ public class HealthMvcEndpointTests { } @Test - public void secureCustomRoleNoAccess() { + public void customRoleShouldNotExposeDetailsForDefaultRole() { this.environment.getPropertySources().addLast(SECURITY_ROLES); given(this.endpoint.invoke()) .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); @@ -178,7 +189,6 @@ public class HealthMvcEndpointTests { @Test public void healthIsCached() { given(this.endpoint.getTimeToLive()).willReturn(10000L); - given(this.endpoint.isSensitive()).willReturn(true); given(this.endpoint.invoke()) .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); Object result = this.mvc.invoke(this.actuator); @@ -188,7 +198,7 @@ public class HealthMvcEndpointTests { assertThat(health.getDetails()).hasSize(1); assertThat(health.getDetails().get("foo")).isEqualTo("bar"); given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build()); - result = this.mvc.invoke(null); // insecure now + result = this.mvc.invoke(this.request); // insecure now assertThat(result instanceof Health).isTrue(); health = (Health) result; // so the result is cached @@ -197,52 +207,16 @@ public class HealthMvcEndpointTests { assertThat(health.getDetails()).isEmpty(); } - @Test - public void insecureAnonymousAccessUnrestricted() { - this.mvc = new HealthMvcEndpoint(this.endpoint, false); - this.mvc.setEnvironment(this.environment); - given(this.endpoint.invoke()) - .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); - assertThat(result instanceof Health).isTrue(); - assertThat(((Health) result).getStatus() == Status.UP).isTrue(); - assertThat(((Health) result).getDetails().get("foo")).isEqualTo("bar"); - } - - @Test - public void insensitiveAnonymousAccessRestricted() { - this.environment.getPropertySources().addLast(NON_SENSITIVE); - given(this.endpoint.invoke()) - .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); - assertThat(result instanceof Health).isTrue(); - assertThat(((Health) result).getStatus() == Status.UP).isTrue(); - assertThat(((Health) result).getDetails().get("foo")).isNull(); - } - - @Test - public void insecureInsensitiveAnonymousAccessUnrestricted() { - this.mvc = new HealthMvcEndpoint(this.endpoint, false); - this.mvc.setEnvironment(this.environment); - this.environment.getPropertySources().addLast(NON_SENSITIVE); - given(this.endpoint.invoke()) - .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); - assertThat(result instanceof Health).isTrue(); - assertThat(((Health) result).getStatus() == Status.UP).isTrue(); - assertThat(((Health) result).getDetails().get("foo")).isEqualTo("bar"); - } - @Test public void noCachingWhenTimeToLiveIsZero() { given(this.endpoint.getTimeToLive()).willReturn(0L); given(this.endpoint.invoke()) .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); + Object result = this.mvc.invoke(this.request); assertThat(result instanceof Health).isTrue(); assertThat(((Health) result).getStatus() == Status.UP).isTrue(); given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build()); - result = this.mvc.invoke(null); + result = this.mvc.invoke(this.request); @SuppressWarnings("unchecked") Health health = ((ResponseEntity) result).getBody(); assertThat(health.getStatus() == Status.DOWN).isTrue(); @@ -251,59 +225,16 @@ public class HealthMvcEndpointTests { @Test public void newValueIsReturnedOnceTtlExpires() throws InterruptedException { given(this.endpoint.getTimeToLive()).willReturn(50L); - given(this.endpoint.isSensitive()).willReturn(false); given(this.endpoint.invoke()) .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); + Object result = this.mvc.invoke(this.request); assertThat(result instanceof Health).isTrue(); assertThat(((Health) result).getStatus() == Status.UP).isTrue(); Thread.sleep(100); given(this.endpoint.invoke()).willReturn(new Health.Builder().down().build()); - result = this.mvc.invoke(null); + result = this.mvc.invoke(this.request); @SuppressWarnings("unchecked") Health health = ((ResponseEntity) result).getBody(); assertThat(health.getStatus() == Status.DOWN).isTrue(); } - - @Test - public void detailIsHiddenWhenAllEndpointsAreSensitive() { - EnvironmentTestUtils.addEnvironment(this.environment, "endpoints.sensitive:true"); - this.mvc = new HealthMvcEndpoint(this.endpoint, false); - this.mvc.setEnvironment(this.environment); - given(this.endpoint.invoke()) - .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); - assertThat(result instanceof Health).isTrue(); - assertThat(((Health) result).getStatus() == Status.UP).isTrue(); - assertThat(((Health) result).getDetails().get("foo")).isNull(); - } - - @Test - public void detailIsHiddenWhenHealthEndpointIsSensitive() { - EnvironmentTestUtils.addEnvironment(this.environment, - "endpoints.health.sensitive:true"); - this.mvc = new HealthMvcEndpoint(this.endpoint, false); - this.mvc.setEnvironment(this.environment); - given(this.endpoint.invoke()) - .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); - assertThat(result instanceof Health).isTrue(); - assertThat(((Health) result).getStatus() == Status.UP).isTrue(); - assertThat(((Health) result).getDetails().get("foo")).isNull(); - } - - @Test - public void detailIsHiddenWhenOnlyHealthEndpointIsSensitive() { - EnvironmentTestUtils.addEnvironment(this.environment, - "endpoints.health.sensitive:true", "endpoints.sensitive:false"); - this.mvc = new HealthMvcEndpoint(this.endpoint, false); - this.mvc.setEnvironment(this.environment); - given(this.endpoint.invoke()) - .willReturn(new Health.Builder().up().withDetail("foo", "bar").build()); - Object result = this.mvc.invoke(null); - assertThat(result instanceof Health).isTrue(); - assertThat(((Health) result).getStatus() == Status.UP).isTrue(); - assertThat(((Health) result).getDetails().get("foo")).isNull(); - } - } diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/NoSpringSecurityHealthMvcEndpointIntegrationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/NoSpringSecurityHealthMvcEndpointIntegrationTests.java index b6e52904dc..c74e18138b 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/NoSpringSecurityHealthMvcEndpointIntegrationTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/NoSpringSecurityHealthMvcEndpointIntegrationTests.java @@ -32,6 +32,7 @@ import org.springframework.boot.autoconfigure.web.HttpMessageConvertersAutoConfi import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; import org.springframework.boot.junit.runner.classpath.ClassPathExclusions; import org.springframework.boot.junit.runner.classpath.ModifiedClassPathRunner; +import org.springframework.boot.test.util.EnvironmentTestUtils; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.mock.web.MockServletContext; @@ -48,6 +49,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * Integration tests for the health endpoint when Spring Security is not available. * * @author Andy Wilkinson + * @author Madhura Bhave */ @RunWith(ModifiedClassPathRunner.class) @ClassPathExclusions("spring-security-*.jar") @@ -61,14 +63,28 @@ public class NoSpringSecurityHealthMvcEndpointIntegrationTests { } @Test - public void healthDetailIsPresent() throws Exception { + public void healthDetailNotPresent() throws Exception { this.context = new AnnotationConfigWebApplicationContext(); this.context.setServletContext(new MockServletContext()); this.context.register(TestConfiguration.class); this.context.refresh(); MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build(); mockMvc.perform(get("/health")).andExpect(status().isOk()) - .andExpect(content().string(containsString("\"hello\":\"world\""))); + .andExpect(content().string(containsString("\"status\":\"UP\""))); + } + + @Test + public void healthDetailPresent() throws Exception { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.setServletContext(new MockServletContext()); + this.context.register(TestConfiguration.class); + EnvironmentTestUtils.addEnvironment(this.context, + "management.security.enabled:false"); + this.context.refresh(); + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build(); + mockMvc.perform(get("/health")).andExpect(status().isOk()) + .andExpect(content().string(containsString( + "\"status\":\"UP\",\"test\":{\"status\":\"UP\",\"hello\":\"world\"}"))); } @ImportAutoConfiguration({ JacksonAutoConfiguration.class, diff --git a/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc b/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc index 2daf97d835..e5feafb767 100644 --- a/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc +++ b/spring-boot-docs/src/main/asciidoc/appendix-application-properties.adoc @@ -1056,6 +1056,8 @@ content into your application; rather pick only the properties that you need. management.add-application-context-header=true # Add the "X-Application-Context" HTTP header in each response. management.address= # Network address that the management endpoints should bind to. management.context-path= # Management endpoint context-path. For instance `/actuator` + management.cloudfoundry.enabled= # Enable extended Cloud Foundry actuator endpoints + management.cloudfoundry.skip-ssl-validation= # Skip SSL verification for Cloud Foundry actuator endpoint security calls management.port= # Management endpoint HTTP port. Uses the same port as the application by default. Configure a different port to use management-specific SSL. management.security.enabled=true # Enable security. management.security.roles=ACTUATOR # Comma-separated list of roles that can access the management endpoint. diff --git a/spring-boot-docs/src/main/asciidoc/build-tool-plugins.adoc b/spring-boot-docs/src/main/asciidoc/build-tool-plugins.adoc index ad5c3ea1bb..999ba86e1d 100644 --- a/spring-boot-docs/src/main/asciidoc/build-tool-plugins.adoc +++ b/spring-boot-docs/src/main/asciidoc/build-tool-plugins.adoc @@ -545,7 +545,7 @@ buildscript { } springBoot { - layoutFactory = new com.example.CustomLayoutFactory() + layoutFactory = new com.example.CustomLayoutFactory() } ---- diff --git a/spring-boot-docs/src/main/asciidoc/howto.adoc b/spring-boot-docs/src/main/asciidoc/howto.adoc index 299784e5e6..217668c23d 100644 --- a/spring-boot-docs/src/main/asciidoc/howto.adoc +++ b/spring-boot-docs/src/main/asciidoc/howto.adoc @@ -177,12 +177,12 @@ element): [source,xml,indent=0] ---- - - - src/main/resources - true - - + + + src/main/resources + true + + ---- and (inside ``): diff --git a/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc b/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc index 2cc0a5c257..dc68920fdd 100644 --- a/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc +++ b/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc @@ -536,11 +536,32 @@ all enabled endpoints to be exposed over HTTP. The default convention is to use [[production-ready-sensitive-endpoints]] -=== Securing sensitive endpoints -If you add '`Spring Security`' to your project, all sensitive endpoints exposed over HTTP -will be protected. By default '`basic`' authentication will be used with the username -`user` and a generated password (which is printed on the console when the application -starts). +=== Accessing sensitive endpoints +By default all sensitive HTTP endpoints are secured such that only users that have an +`ACTUATOR` role may access them. Security is enforced using the standard +`HttpServletRequest.isUserInRole` method. + +TIP: Use the `management.security.roles` property if you want something different to +`ACTUATOR`. + +If you are deploying applications behind a firewall, you may prefer that all your actuator +endpoints can be accessed without requiring authentication. You can do this by changing +the `management.security.enabled` property: + +.application.properties +[source,properties,indent=0] +---- + management.security.enabled=false +---- + +NOTE: By default, actuator endpoints are exposed on the same port that serves regular +HTTP traffic. Take care not to accidentally expose sensitive information if you change +the `management.security.enabled` property. + +If you're deploying applications publicly, you may want to add '`Spring Security`' to +handle user authentication. When '`Spring Security`' is added, by default '`basic`' +authentication will be used with the username `user` and a generated password (which is +printed on the console when the application starts). TIP: Generated passwords are logged as the application starts. Search for '`Using default security password`'. @@ -556,10 +577,6 @@ in your `application.properties`: management.security.roles=SUPERUSER ---- -TIP: If you don't use Spring Security and your HTTP endpoints are exposed publicly, -you should carefully consider which endpoints you enable. See -<> for details of how you can set -`endpoints.enabled` to `false` then "`opt-in`" only specific endpoints. [[production-ready-customizing-management-server-context-path]] @@ -1093,19 +1110,19 @@ Example: [source,java,indent=0] ---- -@Bean -@ExportMetricWriter -MetricWriter metricWriter(MetricExportProperties export) { - return new RedisMetricRepository(connectionFactory, - export.getRedis().getPrefix(), export.getRedis().getKey()); -} + @Bean + @ExportMetricWriter + MetricWriter metricWriter(MetricExportProperties export) { + return new RedisMetricRepository(connectionFactory, + export.getRedis().getPrefix(), export.getRedis().getKey()); + } ---- .application.properties -[source,properties] +[source,properties,indent=0] ---- -spring.metrics.export.redis.prefix: metrics.mysystem.${spring.application.name:application}.${random.value:0000} -spring.metrics.export.redis.key: keys.metrics.mysystem + spring.metrics.export.redis.prefix: metrics.mysystem.${spring.application.name:application}.${random.value:0000} + spring.metrics.export.redis.key: keys.metrics.mysystem ---- The prefix is constructed with the application name and id at the end, so it can easily be used @@ -1144,21 +1161,21 @@ Example: [source,indent=0] ---- -curl localhost:4242/api/query?start=1h-ago&m=max:counter.status.200.root -[ - { - "metric": "counter.status.200.root", - "tags": { - "domain": "org.springframework.metrics", - "process": "b968a76" - }, - "aggregateTags": [], - "dps": { - "1430492872": 2, - "1430492875": 6 + curl localhost:4242/api/query?start=1h-ago&m=max:counter.status.200.root + [ + { + "metric": "counter.status.200.root", + "tags": { + "domain": "org.springframework.metrics", + "process": "b968a76" + }, + "aggregateTags": [], + "dps": { + "1430492872": 2, + "1430492875": 6 + } } - } -] + ] ---- @@ -1177,14 +1194,14 @@ Alternatively, you can provide a `@Bean` of type `StatsdMetricWriter` and mark i [source,java,indent=0] ---- -@Value("${spring.application.name:application}.${random.value:0000}") -private String prefix = "metrics"; + @Value("${spring.application.name:application}.${random.value:0000}") + private String prefix = "metrics"; -@Bean -@ExportMetricWriter -MetricWriter metricWriter() { - return new StatsdMetricWriter(prefix, "localhost", 8125); -} + @Bean + @ExportMetricWriter + MetricWriter metricWriter() { + return new StatsdMetricWriter(prefix, "localhost", 8125); + } ---- @@ -1200,11 +1217,11 @@ Example: [source,java,indent=0] ---- -@Bean -@ExportMetricWriter -MetricWriter metricWriter(MBeanExporter exporter) { - return new JmxMetricWriter(exporter); -} + @Bean + @ExportMetricWriter + MetricWriter metricWriter(MBeanExporter exporter) { + return new JmxMetricWriter(exporter); + } ---- Each metric is exported as an individual MBean. The format for the `ObjectNames` is given @@ -1231,24 +1248,24 @@ Example: [source,java,indent=0] ---- - @Autowired - private MetricExportProperties export; + @Autowired + private MetricExportProperties export; - @Bean - public PublicMetrics metricsAggregate() { - return new MetricReaderPublicMetrics(aggregatesMetricReader()); - } + @Bean + public PublicMetrics metricsAggregate() { + return new MetricReaderPublicMetrics(aggregatesMetricReader()); + } - private MetricReader globalMetricsForAggregation() { - return new RedisMetricRepository(this.connectionFactory, - this.export.getRedis().getAggregatePrefix(), this.export.getRedis().getKey()); - } + private MetricReader globalMetricsForAggregation() { + return new RedisMetricRepository(this.connectionFactory, + this.export.getRedis().getAggregatePrefix(), this.export.getRedis().getKey()); + } - private MetricReader aggregatesMetricReader() { - AggregateMetricReader repository = new AggregateMetricReader( - globalMetricsForAggregation()); - return repository; - } + private MetricReader aggregatesMetricReader() { + AggregateMetricReader repository = new AggregateMetricReader( + globalMetricsForAggregation()); + return repository; + } ---- NOTE: The example above uses `MetricExportProperties` to inject and extract the key and @@ -1312,34 +1329,34 @@ and obtain basic information about the last 100 requests: [source,json,indent=0] ---- -[{ - "timestamp": 1394343677415, - "info": { - "method": "GET", - "path": "/trace", - "headers": { - "request": { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Connection": "keep-alive", - "Accept-Encoding": "gzip, deflate", - "User-Agent": "Mozilla/5.0 Gecko/Firefox", - "Accept-Language": "en-US,en;q=0.5", - "Cookie": "_ga=GA1.1.827067509.1390890128; ..." - "Authorization": "Basic ...", - "Host": "localhost:8080" - }, - "response": { - "Strict-Transport-Security": "max-age=31536000 ; includeSubDomains", - "X-Application-Context": "application:8080", - "Content-Type": "application/json;charset=UTF-8", - "status": "200" - } - } - } - },{ - "timestamp": 1394343684465, - ... - }] + [{ + "timestamp": 1394343677415, + "info": { + "method": "GET", + "path": "/trace", + "headers": { + "request": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Connection": "keep-alive", + "Accept-Encoding": "gzip, deflate", + "User-Agent": "Mozilla/5.0 Gecko/Firefox", + "Accept-Language": "en-US,en;q=0.5", + "Cookie": "_ga=GA1.1.827067509.1390890128; ..." + "Authorization": "Basic ...", + "Host": "localhost:8080" + }, + "response": { + "Strict-Transport-Security": "max-age=31536000 ; includeSubDomains", + "X-Application-Context": "application:8080", + "Content-Type": "application/json;charset=UTF-8", + "status": "200" + } + } + } + },{ + "timestamp": 1394343684465, + ... + }] ---- @@ -1396,6 +1413,67 @@ customize the file name and path via the `Writer` constructor. +[[production-ready-cloudfoundry]] +== Cloud Foundry support +Spring Boot's actuator module includes additional support that is activated when you +deploy to a compatible Cloud Foundry instance. The `/cloudfoundryapplication` path +provides an alternative secured route to all `NamedMvcEndpoint` beans. + +The extended support allows Cloud Foundry management UIs (such as the web +application that you can use to view deployed applications) to be augmented with Spring +Boot actuator information. For example, an application status page may include full health +information instead of the typical "`running`" or "`stopped`" status. + +NOTE: The `/cloudfoundryapplication` path is not directly accessible to regular users. +In order to use the endpoint a valid UAA token must be passed with the request. + + + +[[production-ready-cloudfoundry-disable]] +=== Disabling extended Cloud Foundry actuator support +If you want to fully disable the `/cloudfoundryapplication` endpoints you can add the +following to your `application.properties` file: + + +.application.properties +[source,properties,indent=0] +---- + management.cloudfoundry.enabled=false +---- + + + +[[production-ready-cloudfoundry-ssl]] +=== Cloud Foundry self signed certificates +By default, the security verification for `/cloudfoundryapplication` endpoints makes SSL +calls to various Cloud Foundry services. If your Cloud Foundry UAA or Cloud Controller +services use self-signed certificates you will need to set the following property: + +.application.properties +[source,properties,indent=0] +---- + management.cloudfoundry.skip-ssl-validation=true +---- + + + +[[production-ready-cloudfoundry-custom-security]] +=== Custom security configuration +If you define custom security configuration, and you want extended Cloud Foundry actuator +support, you'll should ensure that `/cloudfoundryapplication/**` paths are open. Without +a direct open route, your Cloud Foundry application manager will not be able to obtain +endpoint data. + +For Spring Security, you'll typically include something like +`mvcMatchers("/cloudfoundryapplication/**").permitAll()` in your configuration: + +[source,java,indent=0] +---- +include::{code-examples}/cloudfoundry/CloudFoundryIgnorePathsExample.java[tag=security] +---- + + + [[production-ready-whats-next]] == What to read next If you want to explore some of the concepts discussed in this chapter, you can take a diff --git a/spring-boot-docs/src/main/asciidoc/using-spring-boot.adoc b/spring-boot-docs/src/main/asciidoc/using-spring-boot.adoc index 0048640e67..374f4ba891 100644 --- a/spring-boot-docs/src/main/asciidoc/using-spring-boot.adoc +++ b/spring-boot-docs/src/main/asciidoc/using-spring-boot.adoc @@ -1123,8 +1123,8 @@ Cloud Foundry you can add the following to your `manifest.yml`: [source,yaml,indent=0] ---- --- - env: - JAVA_OPTS: "-Xdebug -Xrunjdwp:server=y,transport=dt_socket,suspend=n" + env: + JAVA_OPTS: "-Xdebug -Xrunjdwp:server=y,transport=dt_socket,suspend=n" ---- TIP: Notice that you don't need to pass an `address=NNNN` option to `-Xrunjdwp`. If diff --git a/spring-boot-docs/src/main/java/org/springframework/boot/cloudfoundry/CloudFoundryIgnorePathsExample.java b/spring-boot-docs/src/main/java/org/springframework/boot/cloudfoundry/CloudFoundryIgnorePathsExample.java new file mode 100644 index 0000000000..107aca9d1a --- /dev/null +++ b/spring-boot-docs/src/main/java/org/springframework/boot/cloudfoundry/CloudFoundryIgnorePathsExample.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2016 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.cloudfoundry; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * Example for custom Cloud Foundry actuator ignored paths. + * + * @author Phillip Webb + */ +public class CloudFoundryIgnorePathsExample { + + @Configuration + static class CustomSecurityConfiguration extends WebSecurityConfigurerAdapter { + + // @formatter:off + // tag::security[] + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .authorizeRequests() + .mvcMatchers("/cloudfoundryapplication/**") + .permitAll() + .mvcMatchers("/mypath") + .hasAnyRole("SUPERUSER") + .anyRequest() + .authenticated().and() + .httpBasic(); + } + // end::security[] + // @formatter:on + + } + +}