diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java index 4950f22ee4..27763edec3 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscoverer.java @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.autoconfigure.cloudfoundry; import java.util.Collection; +import java.util.Collections; import java.util.List; import org.springframework.aot.hint.MemberCategory; @@ -24,11 +25,13 @@ import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryWebEndpointDiscoverer.CloudFoundryWebEndpointDiscovererRuntimeHints; import org.springframework.boot.actuate.endpoint.EndpointFilter; +import org.springframework.boot.actuate.endpoint.OperationFilter; import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint; import org.springframework.boot.actuate.endpoint.web.PathMapper; +import org.springframework.boot.actuate.endpoint.web.WebOperation; import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer; import org.springframework.boot.actuate.health.HealthEndpoint; @@ -53,14 +56,37 @@ public class CloudFoundryWebEndpointDiscoverer extends WebEndpointDiscoverer { * @param endpointMediaTypes the endpoint media types * @param endpointPathMappers the endpoint path mappers * @param invokerAdvisors invoker advisors to apply - * @param filters filters to apply + * @param endpointFilters endpoint filters to apply + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of + * {@link #CloudFoundryWebEndpointDiscoverer(ApplicationContext, ParameterValueMapper, EndpointMediaTypes, List, Collection, Collection, Collection)} + */ + @Deprecated(since = "3.4.0", forRemoval = true) + public CloudFoundryWebEndpointDiscoverer(ApplicationContext applicationContext, + ParameterValueMapper parameterValueMapper, EndpointMediaTypes endpointMediaTypes, + List endpointPathMappers, Collection invokerAdvisors, + Collection> endpointFilters) { + this(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, invokerAdvisors, + endpointFilters, Collections.emptyList()); + } + + /** + * Create a new {@link WebEndpointDiscoverer} instance. + * @param applicationContext the source application context + * @param parameterValueMapper the parameter value mapper + * @param endpointMediaTypes the endpoint media types + * @param endpointPathMappers the endpoint path mappers + * @param invokerAdvisors invoker advisors to apply + * @param endpointFilters endpoint filters to apply + * @param operationFilters operation filters to apply + * @since 3.4.0 */ public CloudFoundryWebEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper, EndpointMediaTypes endpointMediaTypes, List endpointPathMappers, Collection invokerAdvisors, - Collection> filters) { + Collection> endpointFilters, + Collection> operationFilters) { super(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, null, invokerAdvisors, - filters); + endpointFilters, operationFilters); } @Override diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java index d4e5f43764..f19dd0d744 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java @@ -113,7 +113,8 @@ public class ReactiveCloudFoundryActuatorAutoConfiguration { org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier, ApplicationContext applicationContext) { CloudFoundryWebEndpointDiscoverer endpointDiscoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext, - parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList()); + parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()); CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(webClientBuilder, applicationContext.getEnvironment()); Collection webEndpoints = endpointDiscoverer.getEndpoints(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java index c3e59039c9..f4c234712d 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java @@ -117,7 +117,8 @@ public class CloudFoundryActuatorAutoConfiguration { org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier, ApplicationContext applicationContext) { CloudFoundryWebEndpointDiscoverer discoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext, - parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList()); + parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()); CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(restTemplateBuilder, applicationContext.getEnvironment()); Collection webEndpoints = discoverer.getEndpoints(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAccessOperationFilter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAccessOperationFilter.java new file mode 100644 index 0000000000..37b9383fe7 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAccessOperationFilter.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.endpoint; + +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; +import org.springframework.boot.actuate.endpoint.EndpointId; +import org.springframework.boot.actuate.endpoint.Operation; +import org.springframework.boot.actuate.endpoint.OperationFilter; +import org.springframework.boot.actuate.endpoint.OperationType; + +/** + * An {@link OperationFilter} that filters based on the allowed {@link Access access} as + * determined by an {@link EndpointAccessResolver access resolver}. + * + * @param the operation type + * @author Andy Wilkinson + * @since 3.4.0 + */ +public class EndpointAccessOperationFilter implements OperationFilter { + + private final EndpointAccessResolver accessResolver; + + public EndpointAccessOperationFilter(EndpointAccessResolver accessResolver) { + this.accessResolver = accessResolver; + } + + @Override + public boolean match(O operation, EndpointId endpointId, Access defaultAccess) { + Access access = this.accessResolver.accessFor(endpointId, defaultAccess); + return switch (access) { + case NONE -> false; + case READ_ONLY -> operation.getType() == OperationType.READ; + case UNRESTRICTED -> true; + }; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAutoConfiguration.java index 6448366a80..e2e7bbd13a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 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. @@ -19,6 +19,7 @@ package org.springframework.boot.actuate.autoconfigure.endpoint; import java.util.List; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.EndpointConverter; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; @@ -73,4 +74,10 @@ public class EndpointAutoConfiguration { return new CachingOperationInvokerAdvisor(new EndpointIdTimeToLivePropertyFunction(environment)); } + @Bean + @ConditionalOnMissingBean(EndpointAccessResolver.class) + PropertiesEndpointAccessResolver propertiesEndpointAccessResolver(Environment environment) { + return new PropertiesEndpointAccessResolver(environment); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/PropertiesEndpointAccessResolver.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/PropertiesEndpointAccessResolver.java new file mode 100644 index 0000000000..f3a97dad69 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/PropertiesEndpointAccessResolver.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.endpoint; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; +import org.springframework.boot.actuate.endpoint.EndpointId; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.core.env.PropertyResolver; + +/** + * {@link EndpointAccessResolver} that resolves the permitted level of access to an + * endpoint using the following properties: + *
    + *
  1. {@code management.endpoint..access} or {@code management.endpoint..enabled} + * (deprecated) + *
  2. {@code management.endpoints.access.default} or + * {@code management.endpoints.enabled-by-default} (deprecated) + *
+ * The resulting access is capped using {@code management.endpoints.access.max-permitted}. + * + * @author Andy Wilkinson + * @since 3.4.0 + */ +public class PropertiesEndpointAccessResolver implements EndpointAccessResolver { + + private static final String DEFAULT_ACCESS_KEY = "management.endpoints.access.default"; + + private static final String ENABLED_BY_DEFAULT_KEY = "management.endpoints.enabled-by-default"; + + private final PropertyResolver properties; + + private final Access endpointsDefaultAccess; + + private final Access maxPermittedAccess; + + private final Map accessCache = new ConcurrentHashMap<>(); + + public PropertiesEndpointAccessResolver(PropertyResolver properties) { + this.properties = properties; + this.endpointsDefaultAccess = determineDefaultAccess(properties); + this.maxPermittedAccess = properties.getProperty("management.endpoints.access.max-permitted", Access.class, + Access.UNRESTRICTED); + } + + private static Access determineDefaultAccess(PropertyResolver properties) { + Access defaultAccess = properties.getProperty(DEFAULT_ACCESS_KEY, Access.class); + Boolean endpointsEnabledByDefault = properties.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put(DEFAULT_ACCESS_KEY, defaultAccess); + entries.put(ENABLED_BY_DEFAULT_KEY, endpointsEnabledByDefault); + }); + if (defaultAccess != null) { + return defaultAccess; + } + if (endpointsEnabledByDefault != null) { + return endpointsEnabledByDefault ? org.springframework.boot.actuate.endpoint.Access.UNRESTRICTED + : org.springframework.boot.actuate.endpoint.Access.NONE; + } + return null; + } + + @Override + public Access accessFor(EndpointId endpointId, Access defaultAccess) { + return this.accessCache.computeIfAbsent(endpointId, + (key) -> capAccess(resolveAccess(endpointId, defaultAccess))); + } + + private Access resolveAccess(EndpointId endpointId, Access defaultAccess) { + String accessKey = "management.endpoint.%s.access".formatted(endpointId); + String enabledKey = "management.endpoint.%s.enabled".formatted(endpointId); + Access access = this.properties.getProperty(accessKey, Access.class); + Boolean enabled = this.properties.getProperty(enabledKey, Boolean.class); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put(accessKey, access); + entries.put(enabledKey, enabled); + }); + if (access != null) { + return access; + } + if (enabled != null) { + return (enabled) ? Access.UNRESTRICTED : Access.NONE; + } + return (this.endpointsDefaultAccess != null) ? this.endpointsDefaultAccess : defaultAccess; + } + + private Access capAccess(Access access) { + return Access.values()[Math.min(access.ordinal(), this.maxPermittedAccess.ordinal())]; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java index 5556b86e31..4d3595da8c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/OnAvailableEndpointCondition.java @@ -23,11 +23,13 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Optional; import java.util.Set; +import org.springframework.boot.actuate.autoconfigure.endpoint.PropertiesEndpointAccessResolver; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; @@ -51,7 +53,7 @@ import org.springframework.util.ClassUtils; import org.springframework.util.ConcurrentReferenceHashMap; /** - * A condition that checks if an endpoint is available (i.e. enabled and exposed). + * A condition that checks if an endpoint is available (i.e. accessible and exposed). * * @author Brian Clozel * @author Stephane Nicoll @@ -63,12 +65,10 @@ class OnAvailableEndpointCondition extends SpringBootCondition { private static final String JMX_ENABLED_KEY = "spring.jmx.enabled"; - private static final String ENABLED_BY_DEFAULT_KEY = "management.endpoints.enabled-by-default"; + private static final Map accessResolversCache = new ConcurrentReferenceHashMap<>(); private static final Map> exposureOutcomeContributorsCache = new ConcurrentReferenceHashMap<>(); - private static final ConcurrentReferenceHashMap> enabledByDefaultCache = new ConcurrentReferenceHashMap<>(); - @Override public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { Environment environment = context.getEnvironment(); @@ -114,35 +114,27 @@ class OnAvailableEndpointCondition extends SpringBootCondition { MergedAnnotation endpointAnnotation) { ConditionMessage.Builder message = ConditionMessage.forCondition(ConditionalOnAvailableEndpoint.class); EndpointId endpointId = EndpointId.of(environment, endpointAnnotation.getString("id")); - ConditionOutcome enablementOutcome = getEnablementOutcome(environment, endpointAnnotation, endpointId, message); - ConditionOutcome exposureOutcome = (!enablementOutcome.isMatch()) ? null - : getExposureOutcome(environment, conditionAnnotation, endpointAnnotation, endpointId, message); - return (exposureOutcome != null) ? exposureOutcome - : ConditionOutcome.noMatch(message.because("not enabled or exposed")); + ConditionOutcome accessOutcome = getAccessOutcome(environment, endpointAnnotation, endpointId, message); + if (!accessOutcome.isMatch()) { + return accessOutcome; + } + ConditionOutcome exposureOutcome = getExposureOutcome(environment, conditionAnnotation, endpointAnnotation, + endpointId, message); + return (exposureOutcome != null) ? exposureOutcome : ConditionOutcome.noMatch(message.because("not exposed")); } - private ConditionOutcome getEnablementOutcome(Environment environment, - MergedAnnotation endpointAnnotation, EndpointId endpointId, ConditionMessage.Builder message) { - String key = "management.endpoint." + endpointId.toLowerCaseString() + ".enabled"; - Boolean userDefinedEnabled = environment.getProperty(key, Boolean.class); - if (userDefinedEnabled != null) { - return new ConditionOutcome(userDefinedEnabled, - message.because("found property " + key + " with value " + userDefinedEnabled)); - } - Boolean userDefinedDefault = isEnabledByDefault(environment); - if (userDefinedDefault != null) { - return new ConditionOutcome(userDefinedDefault, message - .because("no property " + key + " found so using user defined default from " + ENABLED_BY_DEFAULT_KEY)); - } - boolean endpointDefault = endpointAnnotation.getBoolean("enableByDefault"); - return new ConditionOutcome(endpointDefault, - message.because("no property " + key + " found so using endpoint default of " + endpointDefault)); + private ConditionOutcome getAccessOutcome(Environment environment, MergedAnnotation endpointAnnotation, + EndpointId endpointId, ConditionMessage.Builder message) { + Access defaultAccess = endpointAnnotation.getEnum("defaultAccess", Access.class); + boolean enableByDefault = endpointAnnotation.getBoolean("enableByDefault"); + Access access = getAccess(environment, endpointId, (enableByDefault) ? defaultAccess : Access.NONE); + return new ConditionOutcome(access != Access.NONE, + message.because("the configured access for endpoint '%s' is %s".formatted(endpointId, access))); } - private Boolean isEnabledByDefault(Environment environment) { - Optional enabledByDefault = enabledByDefaultCache.computeIfAbsent(environment, - (ignore) -> Optional.ofNullable(environment.getProperty(ENABLED_BY_DEFAULT_KEY, Boolean.class))); - return enabledByDefault.orElse(null); + private Access getAccess(Environment environment, EndpointId endpointId, Access defaultAccess) { + return accessResolversCache.computeIfAbsent(environment, PropertiesEndpointAccessResolver::new) + .accessFor(endpointId, defaultAccess); } private ConditionOutcome getExposureOutcome(Environment environment, diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java index 472a8d7009..a65658925a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jmx/JmxEndpointAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 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. @@ -22,10 +22,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.LazyInitializationExcludeFilter; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAccessOperationFilter; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.EndpointFilter; +import org.springframework.boot.actuate.endpoint.OperationFilter; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; @@ -34,6 +37,7 @@ import org.springframework.boot.actuate.endpoint.jmx.ExposableJmxEndpoint; import org.springframework.boot.actuate.endpoint.jmx.JacksonJmxOperationResponseMapper; import org.springframework.boot.actuate.endpoint.jmx.JmxEndpointExporter; import org.springframework.boot.actuate.endpoint.jmx.JmxEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.jmx.JmxOperation; import org.springframework.boot.actuate.endpoint.jmx.JmxOperationResponseMapper; import org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpointDiscoverer; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -80,9 +84,11 @@ public class JmxEndpointAutoConfiguration { @ConditionalOnMissingBean(JmxEndpointsSupplier.class) public JmxEndpointDiscoverer jmxAnnotationEndpointDiscoverer(ParameterValueMapper parameterValueMapper, ObjectProvider invokerAdvisors, - ObjectProvider> filters) { + ObjectProvider> endpointFilters, + ObjectProvider> operationFilters) { return new JmxEndpointDiscoverer(this.applicationContext, parameterValueMapper, - invokerAdvisors.orderedStream().toList(), filters.orderedStream().toList()); + invokerAdvisors.orderedStream().toList(), endpointFilters.orderedStream().toList(), + operationFilters.orderedStream().toList()); } @Bean @@ -116,4 +122,10 @@ public class JmxEndpointAutoConfiguration { return LazyInitializationExcludeFilter.forBeanTypes(JmxEndpointExporter.class); } + @Bean + EndpointAccessOperationFilter jmxAccessPropertiesOperationFilter( + EndpointAccessResolver endpointAccessResolver) { + return new EndpointAccessOperationFilter<>(endpointAccessResolver); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration.java index 37481d1e36..a0f262f383 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration.java @@ -20,6 +20,7 @@ import org.glassfish.jersey.server.ResourceConfig; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter; import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; @@ -62,10 +63,10 @@ public class ServletEndpointManagementContextConfiguration { public org.springframework.boot.actuate.endpoint.web.ServletEndpointRegistrar servletEndpointRegistrar( WebEndpointProperties properties, org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpointsSupplier servletEndpointsSupplier, - DispatcherServletPath dispatcherServletPath) { + DispatcherServletPath dispatcherServletPath, EndpointAccessResolver endpointAccessResolver) { return new org.springframework.boot.actuate.endpoint.web.ServletEndpointRegistrar( dispatcherServletPath.getRelativePath(properties.getBasePath()), - servletEndpointsSupplier.getEndpoints()); + servletEndpointsSupplier.getEndpoints(), endpointAccessResolver); } } @@ -80,10 +81,10 @@ public class ServletEndpointManagementContextConfiguration { public org.springframework.boot.actuate.endpoint.web.ServletEndpointRegistrar servletEndpointRegistrar( WebEndpointProperties properties, org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpointsSupplier servletEndpointsSupplier, - JerseyApplicationPath jerseyApplicationPath) { + JerseyApplicationPath jerseyApplicationPath, EndpointAccessResolver endpointAccessResolver) { return new org.springframework.boot.actuate.endpoint.web.ServletEndpointRegistrar( jerseyApplicationPath.getRelativePath(properties.getBasePath()), - servletEndpointsSupplier.getEndpoints()); + servletEndpointsSupplier.getEndpoints(), endpointAccessResolver); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java index 6ce0eab94f..0c2a9c9c73 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java @@ -20,11 +20,14 @@ import java.util.Collection; import java.util.Collections; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAccessOperationFilter; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointsSupplier; +import org.springframework.boot.actuate.endpoint.OperationFilter; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; @@ -34,6 +37,7 @@ import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint; import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; import org.springframework.boot.actuate.endpoint.web.PathMapper; import org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.WebOperation; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -84,10 +88,12 @@ public class WebEndpointAutoConfiguration { EndpointMediaTypes endpointMediaTypes, ObjectProvider endpointPathMappers, ObjectProvider additionalPathsMappers, ObjectProvider invokerAdvisors, - ObjectProvider> filters) { + ObjectProvider> endpointFilters, + ObjectProvider> operationFilters) { return new WebEndpointDiscoverer(this.applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers.orderedStream().toList(), additionalPathsMappers.orderedStream().toList(), - invokerAdvisors.orderedStream().toList(), filters.orderedStream().toList()); + invokerAdvisors.orderedStream().toList(), endpointFilters.orderedStream().toList(), + operationFilters.orderedStream().toList()); } @Bean @@ -123,6 +129,12 @@ public class WebEndpointAutoConfiguration { exposure.getInclude(), exposure.getExclude()); } + @Bean + EndpointAccessOperationFilter webAccessPropertiesOperationFilter( + EndpointAccessResolver endpointAccessResolver) { + return new EndpointAccessOperationFilter<>(endpointAccessResolver); + } + @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = Type.SERVLET) static class WebEndpointServletConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java index 7dcb1135bc..d49bc60405 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java @@ -36,6 +36,7 @@ import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointPr import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration; import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; @@ -130,10 +131,12 @@ public class WebFluxEndpointManagementContextConfiguration { @Deprecated(since = "3.3.5", forRemoval = true) public org.springframework.boot.actuate.endpoint.web.reactive.ControllerEndpointHandlerMapping controllerEndpointHandlerMapping( org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier, - CorsEndpointProperties corsProperties, WebEndpointProperties webEndpointProperties) { + CorsEndpointProperties corsProperties, WebEndpointProperties webEndpointProperties, + EndpointAccessResolver endpointAccessResolver) { EndpointMapping endpointMapping = new EndpointMapping(webEndpointProperties.getBasePath()); return new org.springframework.boot.actuate.endpoint.web.reactive.ControllerEndpointHandlerMapping( - endpointMapping, controllerEndpointsSupplier.getEndpoints(), corsProperties.toCorsConfiguration()); + endpointMapping, controllerEndpointsSupplier.getEndpoints(), corsProperties.toCorsConfiguration(), + endpointAccessResolver); } @Bean diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java index ee2c8630fe..e5a5e0bdf2 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java @@ -32,6 +32,7 @@ import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointPr import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration; import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; @@ -129,10 +130,12 @@ public class WebMvcEndpointManagementContextConfiguration { @Deprecated(since = "3.3.5", forRemoval = true) public org.springframework.boot.actuate.endpoint.web.servlet.ControllerEndpointHandlerMapping controllerEndpointHandlerMapping( org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier, - CorsEndpointProperties corsProperties, WebEndpointProperties webEndpointProperties) { + CorsEndpointProperties corsProperties, WebEndpointProperties webEndpointProperties, + EndpointAccessResolver endpointAccessResolver) { EndpointMapping endpointMapping = new EndpointMapping(webEndpointProperties.getBasePath()); return new org.springframework.boot.actuate.endpoint.web.servlet.ControllerEndpointHandlerMapping( - endpointMapping, controllerEndpointsSupplier.getEndpoints(), corsProperties.toCorsConfiguration()); + endpointMapping, controllerEndpointsSupplier.getEndpoints(), corsProperties.toCorsConfiguration(), + endpointAccessResolver); } @Bean diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index c1493e3bc4..e05babb51f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -57,10 +57,24 @@ "description": "Whether to validate health group membership on startup. Validation fails if a group includes or excludes a health contributor that does not exist.", "defaultValue": true }, + { + "name": "management.endpoints.access.default", + "type": "java.lang.Boolean", + "description": "Default access level for all endpoints." + }, + { + "name": "management.endpoints.access.max-permitted", + "description": "The maximum level of endpoint access that is permitted. Caps an endpoint's individual access level (management.endpoint..access) and the default access (management.endpoints.access.default).'", + "defaultValue": "unrestricted" + }, { "name": "management.endpoints.enabled-by-default", "type": "java.lang.Boolean", - "description": "Whether to enable or disable all endpoints by default." + "description": "Whether to enable or disable all endpoints by default.", + "deprecation": { + "replacement": "management.endpoints.access.default", + "since": "3.4.0" + } }, { "name": "management.endpoints.jackson.isolated-object-mapper", 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 38b984a501..e412e76d17 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -105,7 +105,8 @@ class CloudFoundryWebEndpointDiscovererTests { Collections.singletonList("application/json")); CloudFoundryWebEndpointDiscoverer discoverer = new CloudFoundryWebEndpointDiscoverer(context, parameterMapper, mediaTypes, Collections.singletonList(endpointPathMapper), - Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList()); + Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList(), + Collections.emptyList()); consumer.accept(discoverer); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java index 7846c80c5c..33d06aa6f7 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java @@ -263,7 +263,7 @@ class CloudFoundryWebFluxEndpointIntegrationTests { ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper( DefaultConversionService.getSharedInstance()); return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null, - Collections.emptyList(), Collections.emptyList()); + Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); } @Bean diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java index 01383c6abf..81440fd0b6 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java @@ -257,7 +257,7 @@ class CloudFoundryMvcWebEndpointIntegrationTests { ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper( DefaultConversionService.getSharedInstance()); return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null, - Collections.emptyList(), Collections.emptyList()); + Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); } @Bean diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAccessOperationFilterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAccessOperationFilterTests.java new file mode 100644 index 0000000000..f3936d030a --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/EndpointAccessOperationFilterTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.endpoint; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; +import org.springframework.boot.actuate.endpoint.EndpointId; +import org.springframework.boot.actuate.endpoint.Operation; +import org.springframework.boot.actuate.endpoint.OperationType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link EndpointAccessOperationFilter}. + * + * @author Andy Wilkinson + */ +class EndpointAccessOperationFilterTests { + + private final EndpointAccessResolver accessResolver = mock(EndpointAccessResolver.class); + + private final Operation operation = mock(Operation.class); + + private final EndpointAccessOperationFilter filter = new EndpointAccessOperationFilter<>( + this.accessResolver); + + @Test + void whenAccessIsUnrestrictedThenMatchReturnsTrue() { + EndpointId endpointId = EndpointId.of("test"); + Access defaultAccess = Access.READ_ONLY; + given(this.accessResolver.accessFor(endpointId, defaultAccess)).willReturn(Access.UNRESTRICTED); + assertThat(this.filter.match(this.operation, endpointId, defaultAccess)).isTrue(); + } + + @Test + void whenAccessIsNoneThenMatchReturnsFalse() { + EndpointId endpointId = EndpointId.of("test"); + Access defaultAccess = Access.READ_ONLY; + given(this.accessResolver.accessFor(endpointId, defaultAccess)).willReturn(Access.NONE); + assertThat(this.filter.match(this.operation, endpointId, defaultAccess)).isFalse(); + } + + @Test + void whenAccessIsReadOnlyAndOperationTypeIsReadThenMatchReturnsTrue() { + EndpointId endpointId = EndpointId.of("test"); + Access defaultAccess = Access.READ_ONLY; + given(this.accessResolver.accessFor(endpointId, defaultAccess)).willReturn(Access.READ_ONLY); + given(this.operation.getType()).willReturn(OperationType.READ); + assertThat(this.filter.match(this.operation, endpointId, defaultAccess)).isTrue(); + } + + @Test + void whenAccessIsReadOnlyAndOperationTypeIsWriteThenMatchReturnsFalse() { + EndpointId endpointId = EndpointId.of("test"); + Access defaultAccess = Access.READ_ONLY; + given(this.accessResolver.accessFor(endpointId, defaultAccess)).willReturn(Access.READ_ONLY); + given(this.operation.getType()).willReturn(OperationType.WRITE); + assertThat(this.filter.match(this.operation, endpointId, defaultAccess)).isFalse(); + } + + @Test + void whenAccessIsReadOnlyAndOperationTypeIsDeleteThenMatchReturnsFalse() { + EndpointId endpointId = EndpointId.of("test"); + Access defaultAccess = Access.READ_ONLY; + given(this.accessResolver.accessFor(endpointId, defaultAccess)).willReturn(Access.READ_ONLY); + given(this.operation.getType()).willReturn(OperationType.DELETE); + assertThat(this.filter.match(this.operation, endpointId, defaultAccess)).isFalse(); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/PropertiesEndpointAccessResolverTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/PropertiesEndpointAccessResolverTests.java new file mode 100644 index 0000000000..9907b4b712 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/PropertiesEndpointAccessResolverTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.endpoint; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointId; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link PropertiesEndpointAccessResolver}. + * + * @author Andy Wilkinson + */ +class PropertiesEndpointAccessResolverTests { + + private final MockEnvironment environment = new MockEnvironment(); + + @Test + void whenNoPropertiesAreConfiguredThenAccessForReturnsEndpointsDefaultAccess() { + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.READ_ONLY); + } + + @Test + void whenDefaultAccessForAllEndpointsIsConfiguredThenAccessForReturnsDefaultForAllEndpoints() { + this.environment.withProperty("management.endpoints.access.default", Access.UNRESTRICTED.name()); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.UNRESTRICTED); + } + + @Test + void whenAccessForEndpointIsConfiguredThenAccessForReturnsIt() { + this.environment.withProperty("management.endpoint.test.access", Access.UNRESTRICTED.name()); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.UNRESTRICTED); + } + + @Test + void whenAccessForEndpointAndDefaultAccessForAllEndpointsAreConfiguredAccessForReturnsAccessForEndpoint() { + this.environment.withProperty("management.endpoint.test.access", Access.NONE.name()) + .withProperty("management.endpoints.access.default", Access.UNRESTRICTED.name()); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.NONE); + } + + @Test + void whenAllEndpointsAreDisabledByDefaultAccessForReturnsNone() { + this.environment.withProperty("management.endpoints.enabled-by-default", "false"); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.NONE); + } + + @Test + void whenAllEndpointsAreEnabledByDefaultAccessForReturnsUnrestricted() { + this.environment.withProperty("management.endpoints.enabled-by-default", "true"); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.UNRESTRICTED); + } + + @Test + void whenEndpointIsDisabledAccessForReturnsNone() { + this.environment.withProperty("management.endpoint.test.enabled", "false"); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.NONE); + } + + @Test + void whenEndpointIsEnabledAccessForReturnsUnrestricted() { + this.environment.withProperty("management.endpoint.test.enabled", "true"); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.UNRESTRICTED); + } + + @Test + void whenEnabledByDefaultAndDefaultAccessAreBothConfiguredResolverCreationThrows() { + this.environment.withProperty("management.endpoints.enabled-by-default", "true") + .withProperty("management.endpoints.access.default", Access.READ_ONLY.name()); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class) + .isThrownBy(this::accessResolver); + } + + @Test + void whenEndpointEnabledAndAccessAreBothConfiguredAccessForThrows() { + this.environment.withProperty("management.endpoint.test.enabled", "true") + .withProperty("management.endpoint.test.access", Access.READ_ONLY.name()); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class) + .isThrownBy(() -> accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)); + } + + @Test + void whenAllEndpointsAreEnabledByDefaultAndAccessIsLimitedToReadOnlyAccessForReturnsReadOnly() { + this.environment.withProperty("management.endpoints.enabled-by-default", "true") + .withProperty("management.endpoints.access.max-permitted", Access.READ_ONLY.name()); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.READ_ONLY); + } + + @Test + void whenAllEndpointsHaveUnrestrictedDefaultAccessAndAccessIsLimitedToReadOnlyAccessForReturnsReadOnly() { + this.environment.withProperty("management.endpoints.access.default", Access.UNRESTRICTED.name()) + .withProperty("management.endpoints.access.max-permitted", Access.READ_ONLY.name()); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.READ_ONLY); + } + + @Test + void whenEndpointsIsEnabledAndAccessIsLimitedToNoneAccessForReturnsNone() { + this.environment.withProperty("management.endpoint.test.enabled", "true") + .withProperty("management.endpoints.access.max-permitted", Access.NONE.name()); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.NONE); + } + + @Test + void whenEndpointsHasUnrestrictedAccessAndAccessIsLimitedToNoneAccessForReturnsNone() { + this.environment.withProperty("management.endpoint.test.access", Access.UNRESTRICTED.name()) + .withProperty("management.endpoints.access.max-permitted", Access.NONE.name()); + assertThat(accessResolver().accessFor(EndpointId.of("test"), Access.READ_ONLY)).isEqualTo(Access.NONE); + } + + private PropertiesEndpointAccessResolver accessResolver() { + return new PropertiesEndpointAccessResolver(this.environment); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/ConditionalOnAvailableEndpointTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/ConditionalOnAvailableEndpointTests.java index 082f60b525..9636b5ac1f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/ConditionalOnAvailableEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/condition/ConditionalOnAvailableEndpointTests.java @@ -19,10 +19,13 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.condition; import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -37,7 +40,9 @@ import static org.assertj.core.api.Assertions.assertThat; class ConditionalOnAvailableEndpointTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(AllEndpointsConfiguration.class); + .withUserConfiguration(AllEndpointsConfiguration.class) + .withInitializer( + (context) -> context.getEnvironment().setConversionService(new ApplicationConversionService())); @Test void outcomeShouldMatchDefaults() { @@ -252,6 +257,43 @@ class ConditionalOnAvailableEndpointTests { .run((context) -> assertThat(context).doesNotHaveBean("unexposed")); } + @Test + void whenBothAccessAndEnabledAreConfiguredThenThrows() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoint.shutdown.enabled=true", "management.endpoint.shutdown.access=none") + .run((context) -> assertThat(context).hasFailed() + .getFailure() + .rootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class)); + } + + @Test + void whenBothDefaultAccessAndDefaultEnabledAreConfiguredThenThrows() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.enabled-by-default=true", "management.endpoints.access.default=none") + .run((context) -> assertThat(context).hasFailed() + .getFailure() + .rootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class)); + } + + @Test + void whenDisabledAndAccessibleByDefaultEndpointIsNotAvailable() { + this.contextRunner.withUserConfiguration(DisabledButAccessibleEndpointConfiguration.class) + .withPropertyValues("management.endpoints.web.exposure.include=*") + .run((context) -> assertThat(context).doesNotHaveBean(DisabledButAccessibleEndpoint.class)); + } + + @Test + void whenDisabledAndAccessibleByDefaultEndpointCanBeAvailable() { + this.contextRunner.withUserConfiguration(DisabledButAccessibleEndpointConfiguration.class) + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=unrestricted") + .run((context) -> assertThat(context).hasSingleBean(DisabledButAccessibleEndpoint.class)); + } + @Endpoint(id = "health") static class HealthEndpoint { @@ -272,7 +314,7 @@ class ConditionalOnAvailableEndpointTests { } - @Endpoint(id = "shutdown", enableByDefault = false) + @Endpoint(id = "shutdown", defaultAccess = Access.NONE) static class ShutdownEndpoint { } @@ -282,6 +324,11 @@ class ConditionalOnAvailableEndpointTests { } + @Endpoint(id = "disabledbutaccessible", enableByDefault = false) + static class DisabledButAccessibleEndpoint { + + } + @EndpointExtension(endpoint = SpringEndpoint.class, filter = TestFilter.class) static class SpringEndpointExtension { @@ -381,4 +428,15 @@ class ConditionalOnAvailableEndpointTests { } + @Configuration(proxyBeanMethods = false) + static class DisabledButAccessibleEndpointConfiguration { + + @Bean + @ConditionalOnAvailableEndpoint + DisabledButAccessibleEndpoint disabledButAccessible() { + return new DisabledButAccessibleEndpoint(); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfigurationTests.java index 8258a3f1f5..bfaa7234cc 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfigurationTests.java @@ -21,6 +21,8 @@ import java.util.Collections; import org.glassfish.jersey.server.ResourceConfig; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.web.ServletEndpointRegistrar; import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpointsSupplier; import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletPath; @@ -94,6 +96,11 @@ class ServletEndpointManagementContextConfigurationTests { return () -> "/jersey"; } + @Bean + EndpointAccessResolver endpointAccessResolver() { + return (endpointId, defaultAccess) -> Access.UNRESTRICTED; + } + } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java index 297497a61b..38692f4b12 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java @@ -27,6 +27,7 @@ import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExc import org.springframework.boot.actuate.endpoint.ApiVersion; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint; import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoint; @@ -140,18 +141,33 @@ class WebEndpointAutoConfigurationTests { @Endpoint(id = "testone") static class TestOneEndpoint { + @ReadOperation + String read() { + return "read"; + } + } @Component @Endpoint(id = "testanotherone") static class TestAnotherOneEndpoint { + @ReadOperation + String read() { + return "read"; + } + } @Component @Endpoint(id = "testtwo") static class TestTwoEndpoint { + @ReadOperation + String read() { + return "read"; + } + } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ShutdownEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ShutdownEndpointDocumentationTests.java index e2d55c4068..e4a742862b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ShutdownEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ShutdownEndpointDocumentationTests.java @@ -24,6 +24,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.test.context.TestPropertySource; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -34,6 +35,7 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.response * * @author Andy Wilkinson */ +@TestPropertySource(properties = "management.endpoint.shutdown.access=unrestricted") class ShutdownEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { @Test diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfigurationTests.java index 4a667841c3..50427a7ebf 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -24,6 +24,8 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.endpoint.web.jersey.JerseyWebEndpointManagementContextConfiguration.JerseyWebEndpointsResourcesRegistrar; import org.springframework.boot.actuate.autoconfigure.web.jersey.JerseySameManagementContextConfiguration; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; @@ -43,7 +45,8 @@ class JerseyWebEndpointManagementContextConfigurationTests { private final WebApplicationContextRunner runner = new WebApplicationContextRunner() .withConfiguration(AutoConfigurations.of(WebEndpointAutoConfiguration.class, JerseyWebEndpointManagementContextConfiguration.class)) - .withBean(WebEndpointsSupplier.class, () -> Collections::emptyList); + .withBean(WebEndpointsSupplier.class, () -> Collections::emptyList) + .withBean(EndpointAccessResolver.class, () -> (endpointId, defaultAccess) -> Access.UNRESTRICTED); @Test void jerseyWebEndpointsResourcesRegistrarForEndpointsIsAutoConfigured() { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JerseyEndpointAccessIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JerseyEndpointAccessIntegrationTests.java new file mode 100644 index 0000000000..79491a3932 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JerseyEndpointAccessIntegrationTests.java @@ -0,0 +1,194 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.integrationtest; + +import java.io.IOException; +import java.time.Duration; +import java.util.function.Supplier; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.beans.BeansEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.jersey.JerseyAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.servlet.DispatcherServlet; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for controlling access to endpoints exposed by Jersey. + * + * @author Andy Wilkinson + */ +class JerseyEndpointAccessIntegrationTests { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner( + AnnotationConfigServletWebServerApplicationContext::new) + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, JerseyAutoConfiguration.class, + EndpointAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class, + WebEndpointAutoConfiguration.class, ManagementContextAutoConfiguration.class, + BeansEndpointAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(DispatcherServlet.class)) + .withUserConfiguration(CustomServletEndpoint.class) + .withPropertyValues("server.port:0"); + + @Test + void accessIsUnrestrictedByDefault() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=*").run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isTrue(); + }); + } + + @Test + void accessCanBeReadOnlyByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=READ_ONLY") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + @Test + void accessCanBeNoneByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=NONE") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + @Test + void accessForOneEndpointCanOverrideTheDefaultAccess() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=NONE", "management.endpoint.customservlet.access=READ_ONLY") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + @Test + void accessCanBeCappedAtReadOnly() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", + "management.endpoints.access.max-permitted=READ_ONLY") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + @Test + void accessCanBeCappedAtNone() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", "management.endpoints.access.max-permitted=NONE") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + private WebTestClient createClient(AssertableWebApplicationContext context) { + int port = context.getSourceApplicationContext(ServletWebServerApplicationContext.class) + .getWebServer() + .getPort(); + ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder() + .codecs((configurer) -> configurer.defaultCodecs().maxInMemorySize(-1)) + .build(); + return WebTestClient.bindToServer() + .baseUrl("http://localhost:" + port) + .exchangeStrategies(exchangeStrategies) + .responseTimeout(Duration.ofMinutes(5)) + .build(); + } + + private boolean isAccessible(WebTestClient client, HttpMethod method, String path) { + path = "/actuator/" + path; + EntityExchangeResult result = client.method(method).uri(path).exchange().expectBody().returnResult(); + if (result.getStatus() == HttpStatus.OK) { + return true; + } + if (result.getStatus() == HttpStatus.NOT_FOUND || result.getStatus() == HttpStatus.METHOD_NOT_ALLOWED) { + return false; + } + throw new IllegalStateException( + String.format("Unexpected %s HTTP status for endpoint %s", result.getStatus(), path)); + } + + @org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint(id = "customservlet") + @SuppressWarnings({ "deprecation", "removal" }) + static class CustomServletEndpoint + implements Supplier { + + @Override + public org.springframework.boot.actuate.endpoint.web.EndpointServlet get() { + return new org.springframework.boot.actuate.endpoint.web.EndpointServlet(new HttpServlet() { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + } + + }); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JmxEndpointAccessIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JmxEndpointAccessIntegrationTests.java new file mode 100644 index 0000000000..b1b9d61e06 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JmxEndpointAccessIntegrationTests.java @@ -0,0 +1,187 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.integrationtest; + +import javax.management.MBeanOperationInfo; +import javax.management.MBeanServer; +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.jmx.JmxEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.exchanges.HttpExchangesAutoConfiguration; +import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; +import org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpoint; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jmx.JmxAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for controlling access to endpoints exposed by JMX. + * + * @author Andy Wilkinson + */ +class JmxEndpointAccessIntegrationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JmxAutoConfiguration.class, EndpointAutoConfiguration.class, + JmxEndpointAutoConfiguration.class, HealthContributorAutoConfiguration.class, + HttpExchangesAutoConfiguration.class)) + .withUserConfiguration(CustomJmxEndpoint.class) + .withPropertyValues("spring.jmx.enabled=true") + .withConfiguration(AutoConfigurations.of(EndpointAutoConfigurationClasses.ALL)); + + @Test + void accessIsUnrestrictedByDefault() { + this.contextRunner.withPropertyValues("management.endpoints.jmx.exposure.include=*").run((context) -> { + MBeanServer mBeanServer = context.getBean(MBeanServer.class); + assertThat(hasOperation(mBeanServer, "beans", "beans")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "read")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "write")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "delete")).isTrue(); + }); + } + + @Test + void accessCanBeReadOnlyByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.jmx.exposure.include=*", + "management.endpoints.access.default=READ_ONLY") + .run((context) -> { + MBeanServer mBeanServer = context.getBean(MBeanServer.class); + assertThat(hasOperation(mBeanServer, "beans", "beans")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "read")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "write")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "delete")).isFalse(); + }); + } + + @Test + void accessCanBeNoneByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.jmx.exposure.include=*", + "management.endpoints.access.default=NONE") + .run((context) -> { + MBeanServer mBeanServer = context.getBean(MBeanServer.class); + assertThat(hasOperation(mBeanServer, "beans", "beans")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "read")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "write")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "delete")).isFalse(); + }); + } + + @Test + void accessForOneEndpointCanOverrideTheDefaultAccess() { + this.contextRunner + .withPropertyValues("management.endpoints.jmx.exposure.include=*", + "management.endpoints.access.default=NONE", "management.endpoint.customjmx.access=UNRESTRICTED") + .run((context) -> { + MBeanServer mBeanServer = context.getBean(MBeanServer.class); + assertThat(hasOperation(mBeanServer, "beans", "beans")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "read")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "write")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "delete")).isTrue(); + }); + } + + @Test + void accessCanBeCappedAtReadOnly() { + this.contextRunner + .withPropertyValues("management.endpoints.jmx.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", + "management.endpoints.access.max-permitted=READ_ONLY") + .run((context) -> { + MBeanServer mBeanServer = context.getBean(MBeanServer.class); + assertThat(hasOperation(mBeanServer, "beans", "beans")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "read")).isTrue(); + assertThat(hasOperation(mBeanServer, "customjmx", "write")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "delete")).isFalse(); + }); + } + + @Test + void accessCanBeCappedAtNone() { + this.contextRunner.withPropertyValues("management.endpoints.jmx.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", "management.endpoints.access.max-permitted=NONE") + .run((context) -> { + MBeanServer mBeanServer = context.getBean(MBeanServer.class); + assertThat(hasOperation(mBeanServer, "beans", "beans")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "read")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "write")).isFalse(); + assertThat(hasOperation(mBeanServer, "customjmx", "delete")).isFalse(); + }); + } + + private ObjectName getDefaultObjectName(String endpointId) { + return getObjectName("org.springframework.boot", endpointId); + } + + private ObjectName getObjectName(String domain, String endpointId) { + try { + return new ObjectName( + String.format("%s:type=Endpoint,name=%s", domain, StringUtils.capitalize(endpointId))); + } + catch (MalformedObjectNameException ex) { + throw new IllegalStateException("Invalid object name", ex); + } + + } + + private boolean hasOperation(MBeanServer mbeanServer, String endpoint, String operationName) { + try { + for (MBeanOperationInfo operation : mbeanServer.getMBeanInfo(getDefaultObjectName(endpoint)) + .getOperations()) { + if (operation.getName().equals(operationName)) { + return true; + } + } + } + catch (Exception ex) { + // Continue + } + return false; + } + + @JmxEndpoint(id = "customjmx") + static class CustomJmxEndpoint { + + @ReadOperation + String read() { + return "read"; + } + + @WriteOperation + String write() { + return "write"; + } + + @DeleteOperation + String delete() { + return "delete"; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebFluxEndpointAccessIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebFluxEndpointAccessIntegrationTests.java new file mode 100644 index 0000000000..341cd7c519 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebFluxEndpointAccessIntegrationTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.integrationtest; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.reactive.ReactiveManagementContextAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebServerApplicationContext; +import org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.reactive.function.client.ExchangeStrategies; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for controlling access to endpoints exposed by Spring WebFlux. + * + * @author Andy Wilkinson + */ +class WebFluxEndpointAccessIntegrationTests { + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner( + AnnotationConfigReactiveWebServerApplicationContext::new) + .withConfiguration(AutoConfigurations.of(ReactiveWebServerFactoryAutoConfiguration.class, + HttpHandlerAutoConfiguration.class, JacksonAutoConfiguration.class, CodecsAutoConfiguration.class, + WebFluxAutoConfiguration.class, EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, + ManagementContextAutoConfiguration.class, ReactiveManagementContextAutoConfiguration.class)) + .withConfiguration(AutoConfigurations.of(EndpointAutoConfigurationClasses.ALL)) + .withUserConfiguration(CustomWebFluxEndpoint.class) + .withPropertyValues("server.port:0"); + + @Test + void accessIsUnrestrictedByDefault() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=*").run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "customwebflux")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customwebflux")).isTrue(); + }); + } + + @Test + void accessCanBeReadOnlyByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=READ_ONLY") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "customwebflux")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customwebflux")).isFalse(); + }); + } + + @Test + void accessCanBeNoneByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=NONE") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customwebflux")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "customwebflux")).isFalse(); + }); + } + + @Test + void accessForOneEndpointCanOverrideTheDefaultAccess() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=NONE", "management.endpoint.customwebflux.access=UNRESTRICTED") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customwebflux")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customwebflux")).isTrue(); + }); + } + + @Test + void accessCanBeCappedAtReadOnly() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", + "management.endpoints.access.max-permitted=READ_ONLY") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "customwebflux")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customwebflux")).isFalse(); + }); + } + + @Test + void accessCanBeCappedAtNone() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", "management.endpoints.access.max-permitted=NONE") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customwebflux")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "customwebflux")).isFalse(); + }); + } + + private WebTestClient createClient(AssertableReactiveWebApplicationContext context) { + int port = context.getSourceApplicationContext(ReactiveWebServerApplicationContext.class) + .getWebServer() + .getPort(); + ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder() + .codecs((configurer) -> configurer.defaultCodecs().maxInMemorySize(-1)) + .build(); + return WebTestClient.bindToServer() + .baseUrl("http://localhost:" + port) + .exchangeStrategies(exchangeStrategies) + .responseTimeout(Duration.ofMinutes(5)) + .build(); + } + + private boolean isAccessible(WebTestClient client, HttpMethod method, String path) { + path = "/actuator/" + path; + EntityExchangeResult result = client.method(method).uri(path).exchange().expectBody().returnResult(); + if (result.getStatus() == HttpStatus.OK) { + return true; + } + if (result.getStatus() == HttpStatus.NOT_FOUND || result.getStatus() == HttpStatus.METHOD_NOT_ALLOWED) { + return false; + } + throw new IllegalStateException( + String.format("Unexpected %s HTTP status for endpoint %s", result.getStatus(), path)); + } + + @org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint(id = "customwebflux") + @SuppressWarnings("removal") + static class CustomWebFluxEndpoint { + + @GetMapping("/") + String get() { + return "get"; + } + + @PostMapping("/") + String post() { + return "post"; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointAccessIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointAccessIntegrationTests.java new file mode 100644 index 0000000000..b574f05fc2 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointAccessIntegrationTests.java @@ -0,0 +1,228 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.autoconfigure.integrationtest; + +import java.io.IOException; +import java.time.Duration; +import java.util.function.Supplier; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.reactive.function.client.ExchangeStrategies; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for controlling access to endpoints exposed by Spring MVC. + * + * @author Andy Wilkinson + */ +class WebMvcEndpointAccessIntegrationTests { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner( + AnnotationConfigServletWebServerApplicationContext::new) + .withConfiguration(AutoConfigurations.of(ServletWebServerFactoryAutoConfiguration.class, + DispatcherServletAutoConfiguration.class, JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, WebMvcAutoConfiguration.class, + EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class, + ManagementContextAutoConfiguration.class, ServletManagementContextAutoConfiguration.class, + HealthContributorAutoConfiguration.class)) + .withConfiguration(AutoConfigurations.of(EndpointAutoConfigurationClasses.ALL)) + .withUserConfiguration(CustomMvcEndpoint.class, CustomServletEndpoint.class) + .withPropertyValues("server.port:0"); + + @Test + void accessIsUnrestrictedByDefault() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=*").run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "custommvc")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "custommvc")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isTrue(); + }); + } + + @Test + void accessCanBeReadOnlyByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=READ_ONLY") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "custommvc")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "custommvc")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + @Test + void accessCanBeNoneByDefault() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=NONE") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "custommvc")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "custommvc")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + @Test + void accessForOneEndpointCanOverrideTheDefaultAccess() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=READ_ONLY", + "management.endpoint.customservlet.access=UNRESTRICTED") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "custommvc")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "custommvc")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isTrue(); + }); + } + + @Test + void accessCanBeCappedAtReadOnly() { + this.contextRunner + .withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", + "management.endpoints.access.max-permitted=READ_ONLY") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isTrue(); + assertThat(isAccessible(client, HttpMethod.GET, "custommvc")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "custommvc")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isTrue(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + @Test + void accessCanBeCappedAtNone() { + this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=*", + "management.endpoints.access.default=UNRESTRICTED", "management.endpoints.access.max-permitted=NONE") + .run((context) -> { + WebTestClient client = createClient(context); + assertThat(isAccessible(client, HttpMethod.GET, "beans")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "custommvc")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "custommvc")).isFalse(); + assertThat(isAccessible(client, HttpMethod.GET, "customservlet")).isFalse(); + assertThat(isAccessible(client, HttpMethod.POST, "customservlet")).isFalse(); + }); + } + + private WebTestClient createClient(AssertableWebApplicationContext context) { + int port = context.getSourceApplicationContext(ServletWebServerApplicationContext.class) + .getWebServer() + .getPort(); + ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder() + .codecs((configurer) -> configurer.defaultCodecs().maxInMemorySize(-1)) + .build(); + return WebTestClient.bindToServer() + .baseUrl("http://localhost:" + port) + .exchangeStrategies(exchangeStrategies) + .responseTimeout(Duration.ofMinutes(5)) + .build(); + } + + private boolean isAccessible(WebTestClient client, HttpMethod method, String path) { + path = "/actuator/" + path; + EntityExchangeResult result = client.method(method).uri(path).exchange().expectBody().returnResult(); + if (result.getStatus() == HttpStatus.OK) { + return true; + } + if (result.getStatus() == HttpStatus.NOT_FOUND || result.getStatus() == HttpStatus.METHOD_NOT_ALLOWED) { + return false; + } + throw new IllegalStateException( + String.format("Unexpected %s HTTP status for endpoint %s", result.getStatus(), path)); + } + + @org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint(id = "custommvc") + @SuppressWarnings("removal") + static class CustomMvcEndpoint { + + @GetMapping("/") + String get() { + return "get"; + } + + @PostMapping("/") + String post() { + return "post"; + } + + } + + @org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint(id = "customservlet") + @SuppressWarnings({ "deprecation", "removal" }) + static class CustomServletEndpoint + implements Supplier { + + @Override + public org.springframework.boot.actuate.endpoint.web.EndpointServlet get() { + return new org.springframework.boot.actuate.endpoint.web.EndpointServlet(new HttpServlet() { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + } + + }); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/ShutdownEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/ShutdownEndpoint.java index 2f19b70cc0..ac7e036967 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/ShutdownEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/ShutdownEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.context; import org.springframework.beans.BeansException; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; @@ -32,7 +33,7 @@ import org.springframework.context.ConfigurableApplicationContext; * @author Andy Wilkinson * @since 2.0.0 */ -@Endpoint(id = "shutdown", enableByDefault = false) +@Endpoint(id = "shutdown", defaultAccess = Access.NONE) public class ShutdownEndpoint implements ApplicationContextAware { private ConfigurableApplicationContext context; diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/AbstractExposableEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/AbstractExposableEndpoint.java index 333333f01a..96108afa53 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/AbstractExposableEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/AbstractExposableEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 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. @@ -32,7 +32,7 @@ public abstract class AbstractExposableEndpoint implements private final EndpointId id; - private final boolean enabledByDefault; + private final Access defaultAccess; private final List operations; @@ -41,12 +41,26 @@ public abstract class AbstractExposableEndpoint implements * @param id the endpoint id * @param enabledByDefault if the endpoint is enabled by default * @param operations the endpoint operations + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of + * {@link #AbstractExposableEndpoint(EndpointId, Access, Collection)} */ + @Deprecated(since = "3.4.0", forRemoval = true) public AbstractExposableEndpoint(EndpointId id, boolean enabledByDefault, Collection operations) { + this(id, (enabledByDefault) ? Access.UNRESTRICTED : Access.READ_ONLY, operations); + } + + /** + * Create a new {@link AbstractExposableEndpoint} instance. + * @param id the endpoint id + * @param defaultAccess access to the endpoint that is permitted by default + * @param operations the endpoint operations + * @since 3.4.0 + */ + public AbstractExposableEndpoint(EndpointId id, Access defaultAccess, Collection operations) { Assert.notNull(id, "ID must not be null"); Assert.notNull(operations, "Operations must not be null"); this.id = id; - this.enabledByDefault = enabledByDefault; + this.defaultAccess = defaultAccess; this.operations = List.copyOf(operations); } @@ -56,8 +70,15 @@ public abstract class AbstractExposableEndpoint implements } @Override + @SuppressWarnings("removal") + @Deprecated(since = "3.4.0", forRemoval = true) public boolean isEnableByDefault() { - return this.enabledByDefault; + return this.defaultAccess != Access.NONE; + } + + @Override + public Access getDefaultAccess() { + return this.defaultAccess; } @Override diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Access.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Access.java new file mode 100644 index 0000000000..dadf0ac9bf --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Access.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2024 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 + * + * https://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; + +/** + * Permitted level of access to an endpoint and its operations. + * + * @author Andy Wilkinson + * @since 3.4.0 + */ +public enum Access { + + /** + * No access to the endpoint is permitted. + */ + NONE, + + /** + * Read-only access to the endpoint is permitted. + */ + READ_ONLY, + + /** + * Unrestricted access to the endpoint is permitted. + */ + UNRESTRICTED + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/EndpointAccessResolver.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/EndpointAccessResolver.java new file mode 100644 index 0000000000..efb4a58eba --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/EndpointAccessResolver.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2024 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 + * + * https://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; + +/** + * Resolver for the permitted level of {@link Access access} to an endpoint. + * + * @author Andy Wilkinson + * @since 3.4.0 + */ +public interface EndpointAccessResolver { + + /** + * Resolves the permitted level of access for the endpoint with the given + * {@code endpointId} and {@code defaultAccess}. + * @param endpointId the ID of the endpoint + * @param defaultAccess the default access level of the endpoint + * @return the permitted level of access, never {@code null} + */ + Access accessFor(EndpointId endpointId, Access defaultAccess); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ExposableEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ExposableEndpoint.java index e535ac00b3..7de3a6ab7d 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ExposableEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ExposableEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 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. @@ -37,9 +37,19 @@ public interface ExposableEndpoint { /** * Returns if the endpoint is enabled by default. * @return if the endpoint is enabled by default + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of + * {@link #getDefaultAccess()} */ + @Deprecated(since = "3.4.0", forRemoval = true) boolean isEnableByDefault(); + /** + * Returns the access to the endpoint that is permitted by default. + * @return access that is permitted by default + * @since 3.4.0 + */ + Access getDefaultAccess(); + /** * Returns the operations of the endpoint. * @return the operations diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationFilter.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationFilter.java new file mode 100644 index 0000000000..9590266e94 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationFilter.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2024 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 + * + * https://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; + +/** + * Strategy class that can be used to filter {@link Operation operations}. + * + * @param the operation type + * @author Andy Wilkinson + * @since 3.4.0 + */ +@FunctionalInterface +public interface OperationFilter { + + /** + * Return {@code true} if the filter matches. + * @param endpointId the ID of the endpoint to which the operation belongs + * @param operation the operation to check + * @param defaultAccess the default permitted level of access to the endpoint + * @return {@code true} if the filter matches + */ + boolean match(O operation, EndpointId endpointId, Access defaultAccess); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java index 91fa1fa403..72a5818103 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -19,6 +19,7 @@ package org.springframework.boot.actuate.endpoint.annotation; import java.util.Collection; import org.springframework.boot.actuate.endpoint.AbstractExposableEndpoint; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.Operation; @@ -47,10 +48,28 @@ public abstract class AbstractDiscoveredEndpoint extends Ab * @param id the ID of the endpoint * @param enabledByDefault if the endpoint is enabled by default * @param operations the endpoint operations + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of + * {@link #AbstractDiscoveredEndpoint(EndpointDiscoverer, Object, EndpointId, Access, Collection)} */ + @SuppressWarnings("removal") + @Deprecated(since = "3.4.0", forRemoval = true) public AbstractDiscoveredEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, boolean enabledByDefault, Collection operations) { - super(id, enabledByDefault, operations); + this(discoverer, endpointBean, id, (enabledByDefault) ? Access.UNRESTRICTED : Access.READ_ONLY, operations); + } + + /** + * Create a new {@link AbstractDiscoveredEndpoint} instance. + * @param discoverer the discoverer that discovered the endpoint + * @param endpointBean the primary source bean + * @param id the ID of the endpoint + * @param defaultAccess access to the endpoint that is permitted by default + * @param operations the endpoint operations + * @since 3.4.0 + */ + public AbstractDiscoveredEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, + Access defaultAccess, Collection operations) { + super(id, defaultAccess, operations); Assert.notNull(discoverer, "Discoverer must not be null"); Assert.notNull(endpointBean, "EndpointBean must not be null"); this.discoverer = discoverer; diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Endpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Endpoint.java index 8f8c793e42..496bf12e80 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Endpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Endpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 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. @@ -23,6 +23,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.aot.hint.annotation.Reflective; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; /** @@ -65,7 +66,16 @@ public @interface Endpoint { /** * If the endpoint should be enabled or disabled by default. * @return {@code true} if the endpoint is enabled by default + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of {@link #defaultAccess()} */ + @Deprecated(since = "3.4.0", forRemoval = true) boolean enableByDefault() default true; + /** + * Level of access to the endpoint that is permitted by default. + * @return the default level of access + * @since 3.4.0 + */ + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java index fa77c0dc69..d28113bbb2 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -33,11 +33,13 @@ import java.util.stream.Collectors; import org.springframework.aop.scope.ScopedProxyUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.EndpointsSupplier; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.Operation; +import org.springframework.boot.actuate.endpoint.OperationFilter; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; @@ -72,7 +74,9 @@ public abstract class EndpointDiscoverer, O exten private final ApplicationContext applicationContext; - private final Collection> filters; + private final Collection> endpointFilters; + + private final Collection> operationFilters; private final DiscoveredOperationsFactory operationsFactory; @@ -85,16 +89,36 @@ public abstract class EndpointDiscoverer, O exten * @param applicationContext the source application context * @param parameterValueMapper the parameter value mapper * @param invokerAdvisors invoker advisors to apply - * @param filters filters to apply + * @param endpointFilters endpoint filters to apply + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of + * {@link #EndpointDiscoverer(ApplicationContext, ParameterValueMapper, Collection, Collection, Collection)} + */ + @Deprecated(since = "3.4.0", forRemoval = true) + public EndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper, + Collection invokerAdvisors, Collection> endpointFilters) { + this(applicationContext, parameterValueMapper, invokerAdvisors, endpointFilters, Collections.emptyList()); + } + + /** + * Create a new {@link EndpointDiscoverer} instance. + * @param applicationContext the source application context + * @param parameterValueMapper the parameter value mapper + * @param invokerAdvisors invoker advisors to apply + * @param endpointFilters endpoint filters to apply + * @param operationFilters operation filters to apply + * @since 3.4.0 */ public EndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper, - Collection invokerAdvisors, Collection> filters) { + Collection invokerAdvisors, Collection> endpointFilters, + Collection> operationFilters) { Assert.notNull(applicationContext, "ApplicationContext must not be null"); Assert.notNull(parameterValueMapper, "ParameterValueMapper must not be null"); Assert.notNull(invokerAdvisors, "InvokerAdvisors must not be null"); - Assert.notNull(filters, "Filters must not be null"); + Assert.notNull(endpointFilters, "EndpointFilters must not be null"); + Assert.notNull(operationFilters, "OperationFilters must not be null"); this.applicationContext = applicationContext; - this.filters = Collections.unmodifiableCollection(filters); + this.endpointFilters = Collections.unmodifiableCollection(endpointFilters); + this.operationFilters = Collections.unmodifiableCollection(operationFilters); this.operationsFactory = getOperationsFactory(parameterValueMapper, invokerAdvisors); } @@ -102,6 +126,11 @@ public abstract class EndpointDiscoverer, O exten Collection invokerAdvisors) { return new DiscoveredOperationsFactory<>(parameterValueMapper, invokerAdvisors) { + @Override + Collection createOperations(EndpointId id, Object target) { + return super.createOperations(id, target); + } + @Override protected O createOperation(EndpointId endpointId, DiscoveredOperationMethod operationMethod, OperationInvoker invoker) { @@ -179,16 +208,31 @@ public abstract class EndpointDiscoverer, O exten Set endpoints = new LinkedHashSet<>(); for (EndpointBean endpointBean : endpointBeans) { if (isEndpointExposed(endpointBean)) { - endpoints.add(convertToEndpoint(endpointBean)); + E endpoint = convertToEndpoint(endpointBean); + if (isInvocable(endpoint)) { + endpoints.add(endpoint); + } } } return Collections.unmodifiableSet(endpoints); } + /** + * Returns whether the endpoint is invocable and should be included in the discovered + * endpoints. The default implementation returns {@code true} if the endpoint has any + * operations, otherwise {@code false}. + * @param endpoint the endpoint to assess + * @return {@code true} if the endpoint is invocable, otherwise {@code false}. + * @since 3.4.0 + */ + protected boolean isInvocable(E endpoint) { + return !endpoint.getOperations().isEmpty(); + } + private E convertToEndpoint(EndpointBean endpointBean) { MultiValueMap indexed = new LinkedMultiValueMap<>(); EndpointId id = endpointBean.getId(); - addOperations(indexed, id, endpointBean.getBean(), false); + addOperations(indexed, id, endpointBean.getDefaultAccess(), endpointBean.getBean(), false); if (endpointBean.getExtensions().size() > 1) { String extensionBeans = endpointBean.getExtensions() .stream() @@ -198,24 +242,26 @@ public abstract class EndpointDiscoverer, O exten + endpointBean.getBeanName() + " (" + extensionBeans + ")"); } for (ExtensionBean extensionBean : endpointBean.getExtensions()) { - addOperations(indexed, id, extensionBean.getBean(), true); + addOperations(indexed, id, endpointBean.getDefaultAccess(), extensionBean.getBean(), true); } assertNoDuplicateOperations(endpointBean, indexed); List operations = indexed.values().stream().map(this::getLast).filter(Objects::nonNull).toList(); - return createEndpoint(endpointBean.getBean(), id, endpointBean.isEnabledByDefault(), operations); + return createEndpoint(endpointBean.getBean(), id, endpointBean.getDefaultAccess(), operations); } - private void addOperations(MultiValueMap indexed, EndpointId id, Object target, - boolean replaceLast) { + private void addOperations(MultiValueMap indexed, EndpointId id, Access defaultAccess, + Object target, boolean replaceLast) { Set replacedLast = new HashSet<>(); Collection operations = this.operationsFactory.createOperations(id, target); for (O operation : operations) { - OperationKey key = createOperationKey(operation); - O last = getLast(indexed.get(key)); - if (replaceLast && replacedLast.add(key) && last != null) { - indexed.get(key).remove(last); + if (!isOperationFiltered(operation, id, defaultAccess)) { + OperationKey key = createOperationKey(operation); + O last = getLast(indexed.get(key)); + if (replaceLast && replacedLast.add(key) && last != null) { + indexed.get(key).remove(last); + } + indexed.add(key, operation); } - indexed.add(key, operation); } } @@ -270,7 +316,7 @@ public abstract class EndpointDiscoverer, O exten } private boolean isEndpointFiltered(EndpointBean endpointBean) { - for (EndpointFilter filter : this.filters) { + for (EndpointFilter filter : this.endpointFilters) { if (!isFilterMatch(filter, endpointBean)) { return true; } @@ -307,14 +353,27 @@ public abstract class EndpointDiscoverer, O exten .get(); } - private E getFilterEndpoint(EndpointBean endpointBean) { - E endpoint = this.filterEndpoints.get(endpointBean); - if (endpoint == null) { - endpoint = createEndpoint(endpointBean.getBean(), endpointBean.getId(), endpointBean.isEnabledByDefault(), - Collections.emptySet()); - this.filterEndpoints.put(endpointBean, endpoint); + private boolean isOperationFiltered(Operation operation, EndpointId endpointId, Access defaultAccess) { + for (OperationFilter filter : this.operationFilters) { + if (!isFilterMatch(filter, operation, endpointId, defaultAccess)) { + return true; + } } - return endpoint; + return false; + } + + @SuppressWarnings("unchecked") + private boolean isFilterMatch(OperationFilter filter, Operation operation, EndpointId endpointId, + Access defaultAccess) { + return LambdaSafe.callback(OperationFilter.class, filter, operation) + .withLogger(EndpointDiscoverer.class) + .invokeAnd((f) -> f.match(operation, endpointId, defaultAccess)) + .get(); + } + + private E getFilterEndpoint(EndpointBean endpointBean) { + return this.filterEndpoints.computeIfAbsent(endpointBean, (key) -> createEndpoint(endpointBean.getBean(), + endpointBean.getId(), endpointBean.getDefaultAccess(), Collections.emptySet())); } @SuppressWarnings("unchecked") @@ -329,8 +388,23 @@ public abstract class EndpointDiscoverer, O exten * @param enabledByDefault if the endpoint is enabled by default * @param operations the endpoint operations * @return a created endpoint (a {@link DiscoveredEndpoint} is recommended) + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of + * {@link #createEndpoint(Object, EndpointId, Access, Collection)} */ - protected abstract E createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault, + @Deprecated(since = "3.4.0", forRemoval = true) + protected E createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault, Collection operations) { + return createEndpoint(endpointBean, id, (enabledByDefault) ? Access.UNRESTRICTED : Access.NONE, operations); + } + + /** + * Factory method called to create the {@link ExposableEndpoint endpoint}. + * @param endpointBean the source endpoint bean + * @param id the ID of the endpoint + * @param defaultAccess access to the endpoint that is permitted by default + * @param operations the endpoint operations + * @return a created endpoint (a {@link DiscoveredEndpoint} is recommended) + */ + protected abstract E createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess, Collection operations); /** @@ -408,7 +482,7 @@ public abstract class EndpointDiscoverer, O exten private final EndpointId id; - private final boolean enabledByDefault; + private final Access defaultAccess; private final Class filter; @@ -424,7 +498,8 @@ public abstract class EndpointDiscoverer, O exten this.beanType = beanType; this.beanSupplier = beanSupplier; this.id = EndpointId.of(environment, id); - this.enabledByDefault = annotation.getBoolean("enableByDefault"); + boolean enabledByDefault = annotation.getBoolean("enableByDefault"); + this.defaultAccess = enabledByDefault ? annotation.getEnum("defaultAccess", Access.class) : Access.NONE; this.filter = getFilter(beanType); } @@ -459,8 +534,8 @@ public abstract class EndpointDiscoverer, O exten return this.id; } - boolean isEnabledByDefault() { - return this.enabledByDefault; + Access getDefaultAccess() { + return this.defaultAccess; } Class getFilter() { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporter.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporter.java index 8a3445f174..7c7f147db5 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporter.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 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. @@ -34,6 +34,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.jmx.JmxException; import org.springframework.jmx.export.MBeanExportException; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; /** * Exports {@link ExposableJmxEndpoint JMX endpoints} to a {@link MBeanServer}. @@ -86,7 +87,11 @@ public class JmxEndpointExporter implements InitializingBean, DisposableBean, Be } private Collection register() { - return this.endpoints.stream().map(this::register).toList(); + return this.endpoints.stream().filter(this::hasOperations).map(this::register).toList(); + } + + private boolean hasOperations(ExposableJmxEndpoint endpoint) { + return !CollectionUtils.isEmpty(endpoint.getOperations()); } private ObjectName register(ExposableJmxEndpoint endpoint) { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java index 3cea7f1957..02edac2821 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 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. @@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.jmx.annotation; import java.util.Collection; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.annotation.AbstractDiscoveredEndpoint; import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer; @@ -31,9 +32,10 @@ import org.springframework.boot.actuate.endpoint.jmx.JmxOperation; */ class DiscoveredJmxEndpoint extends AbstractDiscoveredEndpoint implements ExposableJmxEndpoint { - DiscoveredJmxEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, - boolean enabledByDefault, Collection operations) { - super(discoverer, endpointBean, id, enabledByDefault, operations); + @SuppressWarnings("removal") + DiscoveredJmxEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, Access defaultAccess, + Collection operations) { + super(discoverer, endpointBean, id, defaultAccess, operations); } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpoint.java index 0f0d32426e..ec6761464f 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 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. @@ -22,6 +22,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.FilteredEndpoint; import org.springframework.core.annotation.AliasFor; @@ -50,8 +51,18 @@ public @interface JmxEndpoint { /** * If the endpoint should be enabled or disabled by default. * @return {@code true} if the endpoint is enabled by default + * @deprecated since 3.4.0 for removal in 3.6.0 in favor of */ + @Deprecated(since = "3.4.0", forRemoval = true) @AliasFor(annotation = Endpoint.class) boolean enableByDefault() default true; + /** + * Level of access to the endpoint that is permitted by default. + * @return the default level of access + * @since 3.4.0 + */ + @AliasFor(annotation = Endpoint.class) + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java index a9843e2af4..8a2f1bf2b7 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 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. @@ -17,12 +17,15 @@ package org.springframework.boot.actuate.endpoint.jmx.annotation; import java.util.Collection; +import java.util.Collections; import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointId; +import org.springframework.boot.actuate.endpoint.OperationFilter; import org.springframework.boot.actuate.endpoint.annotation.DiscoveredOperationMethod; import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; @@ -50,18 +53,37 @@ public class JmxEndpointDiscoverer extends EndpointDiscoverer invokerAdvisors, + Collection> endpointFilters) { + this(applicationContext, parameterValueMapper, invokerAdvisors, endpointFilters, Collections.emptyList()); + } + + /** + * Create a new {@link JmxEndpointDiscoverer} instance. + * @param applicationContext the source application context + * @param parameterValueMapper the parameter value mapper + * @param invokerAdvisors invoker advisors to apply + * @param endpointFilters endpoint filters to apply + * @param operationFilters operation filters to apply + * @since 3.4.0 */ public JmxEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper, Collection invokerAdvisors, - Collection> filters) { - super(applicationContext, parameterValueMapper, invokerAdvisors, filters); + Collection> endpointFilters, + Collection> operationFilters) { + super(applicationContext, parameterValueMapper, invokerAdvisors, endpointFilters, operationFilters); } @Override - protected ExposableJmxEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault, + protected ExposableJmxEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess, Collection operations) { - return new DiscoveredJmxEndpoint(this, endpointBean, id, enabledByDefault, operations); + return new DiscoveredJmxEndpoint(this, endpointBean, id, defaultAccess, operations); } @Override diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrar.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrar.java index b7d0a9e44f..70a2253c3d 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrar.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrar.java @@ -16,14 +16,27 @@ package org.springframework.boot.actuate.endpoint.web; +import java.io.IOException; import java.util.Collection; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; import jakarta.servlet.ServletRegistration.Dynamic; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.web.servlet.ServletContextInitializer; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -41,16 +54,26 @@ import org.springframework.util.StringUtils; @SuppressWarnings("removal") public class ServletEndpointRegistrar implements ServletContextInitializer { + private static final Set READ_ONLY_ACCESS_REQUEST_METHODS = Set.of("GET", "HEAD"); + private static final Log logger = LogFactory.getLog(ServletEndpointRegistrar.class); private final String basePath; private final Collection servletEndpoints; + private final EndpointAccessResolver endpointAccessResolver; + public ServletEndpointRegistrar(String basePath, Collection servletEndpoints) { + this(basePath, servletEndpoints, (endpointId, defaultAccess) -> Access.NONE); + } + + public ServletEndpointRegistrar(String basePath, Collection servletEndpoints, + EndpointAccessResolver endpointAccessResolver) { Assert.notNull(servletEndpoints, "ServletEndpoints must not be null"); this.basePath = cleanBasePath(basePath); this.servletEndpoints = servletEndpoints; + this.endpointAccessResolver = endpointAccessResolver; } private static String cleanBasePath(String basePath) { @@ -66,6 +89,10 @@ public class ServletEndpointRegistrar implements ServletContextInitializer { } private void register(ServletContext servletContext, ExposableServletEndpoint endpoint) { + Access access = this.endpointAccessResolver.accessFor(endpoint.getEndpointId(), endpoint.getDefaultAccess()); + if (access == Access.NONE) { + return; + } String name = endpoint.getEndpointId().toLowerCaseString() + "-actuator-endpoint"; String path = this.basePath + "/" + endpoint.getRootPath(); String urlMapping = path.endsWith("/") ? path + "*" : path + "/*"; @@ -74,7 +101,34 @@ public class ServletEndpointRegistrar implements ServletContextInitializer { registration.addMapping(urlMapping); registration.setInitParameters(endpointServlet.getInitParameters()); registration.setLoadOnStartup(endpointServlet.getLoadOnStartup()); + if (access == Access.READ_ONLY) { + servletContext.addFilter(name + "-access-filter", new ReadOnlyAccessFilter()) + .addMappingForServletNames(EnumSet.allOf(DispatcherType.class), false, name); + } logger.info("Registered '" + path + "' to " + name); } + static class ReadOnlyAccessFilter implements Filter { + + private static final int METHOD_NOT_ALLOWED = 405; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (request instanceof HttpServletRequest httpRequest + && response instanceof HttpServletResponse httpResponse) { + if (READ_ONLY_ACCESS_REQUEST_METHODS.contains(httpRequest.getMethod().toUpperCase(Locale.ROOT))) { + chain.doFilter(httpRequest, response); + } + else { + httpResponse.sendError(METHOD_NOT_ALLOWED); + } + } + else { + throw new ServletException(); + } + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java index 334cb81601..02aed521ae 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java @@ -22,6 +22,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.FilteredEndpoint; @@ -71,4 +72,12 @@ public @interface ControllerEndpoint { @AliasFor(annotation = Endpoint.class) boolean enableByDefault() default true; + /** + * Level of access to the endpoint that is permitted by default. + * @return the default level of access + * @since 3.4.0 + */ + @AliasFor(annotation = Endpoint.class) + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java index a6c9cf3a08..08f30dbe22 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java @@ -23,6 +23,7 @@ import java.util.List; import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.Operation; @@ -60,7 +61,7 @@ public class ControllerEndpointDiscoverer extends EndpointDiscoverer endpointPathMappers, Collection> filters) { - super(applicationContext, ParameterValueMapper.NONE, Collections.emptyList(), filters); + super(applicationContext, ParameterValueMapper.NONE, Collections.emptyList(), filters, Collections.emptyList()); this.endpointPathMappers = endpointPathMappers; } @@ -71,10 +72,10 @@ public class ControllerEndpointDiscoverer extends EndpointDiscoverer operations) { String rootPath = PathMapper.getRootPath(this.endpointPathMappers, id); - return new DiscoveredControllerEndpoint(this, endpointBean, id, rootPath, enabledByDefault); + return new DiscoveredControllerEndpoint(this, endpointBean, id, rootPath, defaultAccess); } @Override @@ -88,6 +89,11 @@ public class ControllerEndpointDiscoverer extends EndpointDiscoverer private final String rootPath; DiscoveredControllerEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, - String rootPath, boolean enabledByDefault) { - super(discoverer, endpointBean, id, enabledByDefault, Collections.emptyList()); + String rootPath, Access defaultAccess) { + super(discoverer, endpointBean, id, defaultAccess, Collections.emptyList()); this.rootPath = rootPath; } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredServletEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredServletEndpoint.java index 9a9e67aaf5..e7b45d2928 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredServletEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredServletEndpoint.java @@ -19,6 +19,7 @@ package org.springframework.boot.actuate.endpoint.web.annotation; import java.util.Collections; import java.util.function.Supplier; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.Operation; import org.springframework.boot.actuate.endpoint.annotation.AbstractDiscoveredEndpoint; @@ -40,8 +41,8 @@ class DiscoveredServletEndpoint extends AbstractDiscoveredEndpoint im private final EndpointServlet endpointServlet; DiscoveredServletEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, String rootPath, - boolean enabledByDefault) { - super(discoverer, endpointBean, id, enabledByDefault, Collections.emptyList()); + Access defaultAccess) { + super(discoverer, endpointBean, id, defaultAccess, Collections.emptyList()); String beanType = endpointBean.getClass().getName(); Assert.state(endpointBean instanceof Supplier, () -> "ServletEndpoint bean " + beanType + " must be a supplier"); diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java index 7543284f46..5850e0318d 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java @@ -20,6 +20,7 @@ import java.util.Collection; import java.util.List; import java.util.stream.Stream; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.annotation.AbstractDiscoveredEndpoint; import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer; @@ -40,9 +41,9 @@ class DiscoveredWebEndpoint extends AbstractDiscoveredEndpoint imp private Collection additionalPathsMappers; DiscoveredWebEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, String rootPath, - boolean enabledByDefault, Collection operations, + Access defaultAccess, Collection operations, Collection additionalPathsMappers) { - super(discoverer, endpointBean, id, enabledByDefault, operations); + super(discoverer, endpointBean, id, defaultAccess, operations); this.rootPath = rootPath; this.additionalPathsMappers = additionalPathsMappers; } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java index 26dd043b08..549f8e1a58 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java @@ -22,6 +22,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.FilteredEndpoint; @@ -73,4 +74,12 @@ public @interface RestControllerEndpoint { @AliasFor(annotation = Endpoint.class) boolean enableByDefault() default true; + /** + * Level of access to the endpoint that is permitted by default. + * @return the default level of access + * @since 3.4.0 + */ + @AliasFor(annotation = Endpoint.class) + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpoint.java index 5a2dc80e96..ca1480015c 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpoint.java @@ -23,6 +23,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.function.Supplier; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.FilteredEndpoint; import org.springframework.boot.actuate.endpoint.web.EndpointServlet; @@ -64,4 +65,12 @@ public @interface ServletEndpoint { @AliasFor(annotation = Endpoint.class) boolean enableByDefault() default true; + /** + * Level of access to the endpoint that is permitted by default. + * @return the default level of access + * @since 3.4.0 + */ + @AliasFor(annotation = Endpoint.class) + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java index 9d7b6606d0..9d8804af0c 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java @@ -23,6 +23,7 @@ import java.util.List; import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.Operation; @@ -60,7 +61,7 @@ public class ServletEndpointDiscoverer extends EndpointDiscoverer endpointPathMappers, Collection> filters) { - super(applicationContext, ParameterValueMapper.NONE, Collections.emptyList(), filters); + super(applicationContext, ParameterValueMapper.NONE, Collections.emptyList(), filters, Collections.emptyList()); this.endpointPathMappers = endpointPathMappers; } @@ -70,10 +71,10 @@ public class ServletEndpointDiscoverer extends EndpointDiscoverer operations) { String rootPath = PathMapper.getRootPath(this.endpointPathMappers, id); - return new DiscoveredServletEndpoint(this, endpointBean, id, rootPath, enabledByDefault); + return new DiscoveredServletEndpoint(this, endpointBean, id, rootPath, defaultAccess); } @Override @@ -87,6 +88,11 @@ public class ServletEndpointDiscoverer extends EndpointDiscoverer invokerAdvisors, Collection> filters) { this(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, Collections.emptyList(), - invokerAdvisors, filters); + invokerAdvisors, filters, Collections.emptyList()); } /** @@ -85,14 +87,16 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer endpointPathMappers, List additionalPathsMappers, Collection invokerAdvisors, - Collection> filters) { - super(applicationContext, parameterValueMapper, invokerAdvisors, filters); + Collection> endpointFilters, + Collection> operationFilters) { + super(applicationContext, parameterValueMapper, invokerAdvisors, endpointFilters, operationFilters); this.endpointPathMappers = (endpointPathMappers != null) ? endpointPathMappers : Collections.emptyList(); this.additionalPathsMappers = (additionalPathsMappers != null) ? additionalPathsMappers : Collections.emptyList(); @@ -100,10 +104,10 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer operations) { String rootPath = PathMapper.getRootPath(this.endpointPathMappers, id); - return new DiscoveredWebEndpoint(this, endpointBean, id, rootPath, enabledByDefault, operations, + return new DiscoveredWebEndpoint(this, endpointBean, id, rootPath, defaultAccess, operations, this.additionalPathsMappers); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java index e36021c59f..07f7d1381b 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java @@ -19,13 +19,19 @@ package org.springframework.boot.actuate.endpoint.web.reactive; import java.lang.reflect.Method; import java.util.Collection; import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.annotation.ExposableControllerEndpoint; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.result.method.RequestMappingInfo; @@ -47,12 +53,17 @@ import org.springframework.web.util.pattern.PathPattern; @SuppressWarnings("removal") public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMapping { + private static final Set READ_ONLY_ACCESS_REQUEST_METHODS = EnumSet.of(RequestMethod.GET, + RequestMethod.HEAD); + private final EndpointMapping endpointMapping; private final CorsConfiguration corsConfiguration; private final Map handlers; + private final EndpointAccessResolver accessResolver; + /** * Create a new {@link ControllerEndpointHandlerMapping} instance providing mappings * for the specified endpoints. @@ -62,11 +73,26 @@ public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMappi */ public ControllerEndpointHandlerMapping(EndpointMapping endpointMapping, Collection endpoints, CorsConfiguration corsConfiguration) { + this(endpointMapping, endpoints, corsConfiguration, (endpointId, defaultAccess) -> Access.NONE); + } + + /** + * Create a new {@link ControllerEndpointHandlerMapping} instance providing mappings + * for the specified endpoints. + * @param endpointMapping the base mapping for all endpoints + * @param endpoints the web endpoints + * @param corsConfiguration the CORS configuration for the endpoints or {@code null} + * @param endpointAccessResolver resolver for endpoint access + */ + public ControllerEndpointHandlerMapping(EndpointMapping endpointMapping, + Collection endpoints, CorsConfiguration corsConfiguration, + EndpointAccessResolver endpointAccessResolver) { Assert.notNull(endpointMapping, "EndpointMapping must not be null"); Assert.notNull(endpoints, "Endpoints must not be null"); this.endpointMapping = endpointMapping; this.handlers = getHandlers(endpoints); this.corsConfiguration = corsConfiguration; + this.accessResolver = endpointAccessResolver; setOrder(-100); } @@ -84,10 +110,32 @@ public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMappi @Override protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) { ExposableControllerEndpoint endpoint = this.handlers.get(handler); + Access access = this.accessResolver.accessFor(endpoint.getEndpointId(), endpoint.getDefaultAccess()); + if (access == Access.NONE) { + return; + } + if (access == Access.READ_ONLY) { + mapping = withReadOnlyAccess(access, mapping); + if (CollectionUtils.isEmpty(mapping.getMethodsCondition().getMethods())) { + return; + } + } mapping = withEndpointMappedPatterns(endpoint, mapping); super.registerHandlerMethod(handler, method, mapping); } + private RequestMappingInfo withReadOnlyAccess(Access access, RequestMappingInfo mapping) { + Set methods = mapping.getMethodsCondition().getMethods(); + Set modifiedMethods = new HashSet<>(methods); + if (modifiedMethods.isEmpty()) { + modifiedMethods.addAll(READ_ONLY_ACCESS_REQUEST_METHODS); + } + else { + modifiedMethods.retainAll(READ_ONLY_ACCESS_REQUEST_METHODS); + } + return mapping.mutate().methods(modifiedMethods.toArray(new RequestMethod[0])).build(); + } + private RequestMappingInfo withEndpointMappedPatterns(ExposableControllerEndpoint endpoint, RequestMappingInfo mapping) { Set patterns = mapping.getPatternsCondition().getPatterns(); diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java index c6bc83611b..b7085b9171 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java @@ -19,14 +19,20 @@ package org.springframework.boot.actuate.endpoint.web.servlet; import java.lang.reflect.Method; import java.util.Collection; import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.annotation.ExposableControllerEndpoint; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; @@ -48,12 +54,17 @@ import org.springframework.web.util.pattern.PathPattern; @SuppressWarnings("removal") public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMapping { + private static final Set READ_ONLY_ACCESS_REQUEST_METHODS = EnumSet.of(RequestMethod.GET, + RequestMethod.HEAD); + private final EndpointMapping endpointMapping; private final CorsConfiguration corsConfiguration; private final Map handlers; + private final EndpointAccessResolver accessResolver; + /** * Create a new {@link ControllerEndpointHandlerMapping} instance providing mappings * for the specified endpoints. @@ -63,11 +74,26 @@ public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMappi */ public ControllerEndpointHandlerMapping(EndpointMapping endpointMapping, Collection endpoints, CorsConfiguration corsConfiguration) { + this(endpointMapping, endpoints, corsConfiguration, (endpointId, defaultAccess) -> Access.NONE); + } + + /** + * Create a new {@link ControllerEndpointHandlerMapping} instance providing mappings + * for the specified endpoints. + * @param endpointMapping the base mapping for all endpoints + * @param endpoints the web endpoints + * @param corsConfiguration the CORS configuration for the endpoints or {@code null} + * @param endpointAccessResolver resolver for endpoint access + */ + public ControllerEndpointHandlerMapping(EndpointMapping endpointMapping, + Collection endpoints, CorsConfiguration corsConfiguration, + EndpointAccessResolver endpointAccessResolver) { Assert.notNull(endpointMapping, "EndpointMapping must not be null"); Assert.notNull(endpoints, "Endpoints must not be null"); this.endpointMapping = endpointMapping; this.handlers = getHandlers(endpoints); this.corsConfiguration = corsConfiguration; + this.accessResolver = endpointAccessResolver; setOrder(-100); } @@ -85,10 +111,32 @@ public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMappi @Override protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) { ExposableControllerEndpoint endpoint = this.handlers.get(handler); + Access access = this.accessResolver.accessFor(endpoint.getEndpointId(), endpoint.getDefaultAccess()); + if (access == Access.NONE) { + return; + } + if (access == Access.READ_ONLY) { + mapping = withReadOnlyAccess(access, mapping); + if (CollectionUtils.isEmpty(mapping.getMethodsCondition().getMethods())) { + return; + } + } mapping = withEndpointMappedPatterns(endpoint, mapping); super.registerHandlerMethod(handler, method, mapping); } + private RequestMappingInfo withReadOnlyAccess(Access access, RequestMappingInfo mapping) { + Set methods = mapping.getMethodsCondition().getMethods(); + Set modifiedMethods = new HashSet<>(methods); + if (modifiedMethods.isEmpty()) { + modifiedMethods.addAll(READ_ONLY_ACCESS_REQUEST_METHODS); + } + else { + modifiedMethods.retainAll(READ_ONLY_ACCESS_REQUEST_METHODS); + } + return mapping.mutate().methods(modifiedMethods.toArray(new RequestMethod[0])).build(); + } + private RequestMappingInfo withEndpointMappedPatterns(ExposableControllerEndpoint endpoint, RequestMappingInfo mapping) { Set patterns = mapping.getPathPatternsCondition().getPatterns(); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscovererEndpointFilterTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscovererEndpointFilterTests.java index c619700eab..b457b2fa95 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscovererEndpointFilterTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/DiscovererEndpointFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.endpoint.annotation; import java.util.Collection; +import java.util.Collections; import org.junit.jupiter.api.Test; @@ -79,7 +80,7 @@ class DiscovererEndpointFilterTests { TestDiscovererA(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper, Collection invokerAdvisors, Collection>> filters) { - super(applicationContext, parameterValueMapper, invokerAdvisors, filters); + super(applicationContext, parameterValueMapper, invokerAdvisors, filters, Collections.emptyList()); } } @@ -89,7 +90,7 @@ class DiscovererEndpointFilterTests { TestDiscovererB(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper, Collection invokerAdvisors, Collection>> filters) { - super(applicationContext, parameterValueMapper, invokerAdvisors, filters); + super(applicationContext, parameterValueMapper, invokerAdvisors, filters, Collections.emptyList()); } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java index 96b68a2a56..131e0e76ef 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -34,10 +34,13 @@ import java.util.function.Function; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.Operation; +import org.springframework.boot.actuate.endpoint.OperationFilter; +import org.springframework.boot.actuate.endpoint.OperationType; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; @@ -251,19 +254,33 @@ class EndpointDiscovererTests { } @Test - void getEndpointsShouldApplyFilters() { + void getEndpointsShouldApplyEndpointFilters() { load(SpecializedEndpointsConfiguration.class, (context) -> { EndpointFilter filter = (endpoint) -> { EndpointId id = endpoint.getEndpointId(); return !id.equals(EndpointId.of("specialized")) && !id.equals(EndpointId.of("specialized-superclass")); }; SpecializedEndpointDiscoverer discoverer = new SpecializedEndpointDiscoverer(context, - Collections.singleton(filter)); + Collections.singleton(filter), Collections.emptyList()); Map endpoints = mapEndpoints(discoverer.getEndpoints()); assertThat(endpoints).containsOnlyKeys(EndpointId.of("test")); }); } + @Test + void getEndpointsShouldApplyOperationFilters() { + load(SpecializedEndpointsConfiguration.class, (context) -> { + OperationFilter operationFilter = (operation, endpointId, + defaultAccess) -> operation.getType() == OperationType.READ; + SpecializedEndpointDiscoverer discoverer = new SpecializedEndpointDiscoverer(context, + Collections.emptyList(), List.of(operationFilter)); + Map endpoints = mapEndpoints(discoverer.getEndpoints()); + assertThat(endpoints.values()) + .allSatisfy((endpoint) -> assertThat(endpoint.getOperations()).extracting(SpecializedOperation::getType) + .containsOnly(OperationType.READ)); + }); + } + private void hasTestEndpoint(AnnotationConfigApplicationContext context) { TestEndpointDiscoverer discoverer = new TestEndpointDiscoverer(context); Map endpoints = mapEndpoints(discoverer.getEndpoints()); @@ -536,10 +553,17 @@ class EndpointDiscovererTests { TestEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper, Collection invokerAdvisors, Collection> filters) { - super(applicationContext, parameterValueMapper, invokerAdvisors, filters); + super(applicationContext, parameterValueMapper, invokerAdvisors, filters, Collections.emptyList()); } @Override + protected TestExposableEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess, + Collection operations) { + return new TestExposableEndpoint(this, endpointBean, id, defaultAccess, operations); + } + + @Override + @SuppressWarnings("removal") protected TestExposableEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault, Collection operations) { return new TestExposableEndpoint(this, endpointBean, id, enabledByDefault, operations); @@ -563,15 +587,24 @@ class EndpointDiscovererTests { extends EndpointDiscoverer { SpecializedEndpointDiscoverer(ApplicationContext applicationContext) { - this(applicationContext, Collections.emptyList()); + this(applicationContext, Collections.emptyList(), Collections.emptyList()); } SpecializedEndpointDiscoverer(ApplicationContext applicationContext, - Collection> filters) { - super(applicationContext, new ConversionServiceParameterValueMapper(), Collections.emptyList(), filters); + Collection> filters, + Collection> operationFilters) { + super(applicationContext, new ConversionServiceParameterValueMapper(), Collections.emptyList(), filters, + operationFilters); } @Override + protected SpecializedExposableEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess, + Collection operations) { + return new SpecializedExposableEndpoint(this, endpointBean, id, defaultAccess, operations); + } + + @Override + @SuppressWarnings("removal") protected SpecializedExposableEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault, Collection operations) { return new SpecializedExposableEndpoint(this, endpointBean, id, enabledByDefault, operations); @@ -593,6 +626,12 @@ class EndpointDiscovererTests { static class TestExposableEndpoint extends AbstractDiscoveredEndpoint { + TestExposableEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, + Access defaultAccess, Collection operations) { + super(discoverer, endpointBean, id, defaultAccess, operations); + } + + @SuppressWarnings("removal") TestExposableEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, boolean enabledByDefault, Collection operations) { super(discoverer, endpointBean, id, enabledByDefault, operations); @@ -602,6 +641,13 @@ class EndpointDiscovererTests { static class SpecializedExposableEndpoint extends AbstractDiscoveredEndpoint { + @SuppressWarnings("removal") + SpecializedExposableEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, + Access defaultAccess, Collection operations) { + super(discoverer, endpointBean, id, defaultAccess, operations); + } + + @SuppressWarnings("removal") SpecializedExposableEndpoint(EndpointDiscoverer discoverer, Object endpointBean, EndpointId id, boolean enabledByDefault, Collection operations) { super(discoverer, endpointBean, id, enabledByDefault, operations); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporterTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporterTests.java index 37a3978761..cfd0a8d216 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporterTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/JmxEndpointExporterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -136,6 +136,13 @@ class JmxEndpointExporterTests { .withMessageContaining("Failed to register MBean for endpoint 'test"); } + @Test + void registerWhenEndpointHasNoOperationsShouldNotCreateMBean() { + this.endpoints.add(new TestExposableJmxEndpoint()); + this.exporter.afterPropertiesSet(); + then(this.mBeanServer).shouldHaveNoInteractions(); + } + @Test void destroyShouldUnregisterMBeans() throws Exception { this.endpoints.add(new TestExposableJmxEndpoint(new TestJmxOperation())); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/TestExposableJmxEndpoint.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/TestExposableJmxEndpoint.java index 33c04a9013..3d78ebb9c4 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/TestExposableJmxEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/TestExposableJmxEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 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. @@ -19,6 +19,7 @@ package org.springframework.boot.actuate.endpoint.jmx; import java.util.Arrays; import java.util.Collection; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; /** @@ -44,6 +45,7 @@ public class TestExposableJmxEndpoint implements ExposableJmxEndpoint { } @Override + @SuppressWarnings("removal") public boolean isEnableByDefault() { return true; } @@ -53,4 +55,9 @@ public class TestExposableJmxEndpoint implements ExposableJmxEndpoint { return this.operations; } + @Override + public Access getDefaultAccess() { + return Access.UNRESTRICTED; + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscovererTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscovererTests.java index fc4465f398..75261ddb29 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscovererTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscovererTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 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. @@ -280,7 +280,8 @@ class JmxEndpointDiscovererTests { ConversionServiceParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper( DefaultConversionService.getSharedInstance()); JmxEndpointDiscoverer discoverer = new JmxEndpointDiscoverer(context, parameterMapper, - Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList()); + Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList(), + Collections.emptyList()); consumer.accept(discoverer); } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrarTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrarTests.java index fc051c7cf4..6e9c9dacae 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrarTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/ServletEndpointRegistrarTests.java @@ -17,12 +17,16 @@ package org.springframework.boot.actuate.endpoint.web; import java.util.Collections; +import java.util.EnumSet; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterRegistration; import jakarta.servlet.GenericServlet; import jakarta.servlet.Servlet; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRegistration.Dynamic; +import jakarta.servlet.ServletRegistration; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import org.junit.jupiter.api.Test; @@ -30,6 +34,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; import static org.assertj.core.api.Assertions.assertThat; @@ -55,7 +60,10 @@ class ServletEndpointRegistrarTests { private ServletContext servletContext; @Mock - private Dynamic dynamic; + private ServletRegistration.Dynamic servletDynamic; + + @Mock + private FilterRegistration.Dynamic filterDynamic; @Test void createWhenServletEndpointsIsNullShouldThrowException() { @@ -84,42 +92,77 @@ class ServletEndpointRegistrarTests { } private void assertBasePath(String basePath, String expectedMapping) throws ServletException { - given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.dynamic); + given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.servletDynamic); ExposableServletEndpoint endpoint = mockEndpoint(new EndpointServlet(TestServlet.class)); - ServletEndpointRegistrar registrar = new ServletEndpointRegistrar(basePath, Collections.singleton(endpoint)); + ServletEndpointRegistrar registrar = new ServletEndpointRegistrar(basePath, Collections.singleton(endpoint), + (endpointId, defaultAccess) -> Access.UNRESTRICTED); registrar.onStartup(this.servletContext); then(this.servletContext).should() .addServlet(eq("test-actuator-endpoint"), (Servlet) assertArg((servlet) -> assertThat(servlet).isInstanceOf(TestServlet.class))); - then(this.dynamic).should().addMapping(expectedMapping); + then(this.servletDynamic).should().addMapping(expectedMapping); + then(this.servletContext).shouldHaveNoMoreInteractions(); } @Test void onStartupWhenHasInitParametersShouldRegisterInitParameters() throws Exception { - given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.dynamic); + given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.servletDynamic); ExposableServletEndpoint endpoint = mockEndpoint( new EndpointServlet(TestServlet.class).withInitParameter("a", "b")); - ServletEndpointRegistrar registrar = new ServletEndpointRegistrar("/actuator", Collections.singleton(endpoint)); + ServletEndpointRegistrar registrar = new ServletEndpointRegistrar("/actuator", Collections.singleton(endpoint), + (endpointId, defaultAccess) -> Access.UNRESTRICTED); registrar.onStartup(this.servletContext); - then(this.dynamic).should().setInitParameters(Collections.singletonMap("a", "b")); + then(this.servletDynamic).should().setInitParameters(Collections.singletonMap("a", "b")); } @Test void onStartupWhenHasLoadOnStartupShouldRegisterLoadOnStartup() throws Exception { - given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.dynamic); + given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.servletDynamic); ExposableServletEndpoint endpoint = mockEndpoint(new EndpointServlet(TestServlet.class).withLoadOnStartup(7)); - ServletEndpointRegistrar registrar = new ServletEndpointRegistrar("/actuator", Collections.singleton(endpoint)); + ServletEndpointRegistrar registrar = new ServletEndpointRegistrar("/actuator", Collections.singleton(endpoint), + (endpointId, defaultAccess) -> Access.UNRESTRICTED); registrar.onStartup(this.servletContext); - then(this.dynamic).should().setLoadOnStartup(7); + then(this.servletDynamic).should().setLoadOnStartup(7); } @Test void onStartupWhenHasNotLoadOnStartupShouldRegisterDefaultValue() throws Exception { - given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.dynamic); + given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.servletDynamic); ExposableServletEndpoint endpoint = mockEndpoint(new EndpointServlet(TestServlet.class)); + ServletEndpointRegistrar registrar = new ServletEndpointRegistrar("/actuator", Collections.singleton(endpoint), + (endpointId, defaultAccess) -> Access.UNRESTRICTED); + registrar.onStartup(this.servletContext); + then(this.servletDynamic).should().setLoadOnStartup(-1); + } + + @Test + void onStartupWhenAccessIsDisabledShouldNotRegister() throws Exception { + ExposableServletEndpoint endpoint = mock(ExposableServletEndpoint.class); + given(endpoint.getEndpointId()).willReturn(EndpointId.of("test")); ServletEndpointRegistrar registrar = new ServletEndpointRegistrar("/actuator", Collections.singleton(endpoint)); registrar.onStartup(this.servletContext); - then(this.dynamic).should().setLoadOnStartup(-1); + then(this.servletContext).shouldHaveNoInteractions(); + } + + @Test + void onStartupWhenAccessIsReadOnlyShouldRegisterServletWithFilter() throws Exception { + ExposableServletEndpoint endpoint = mockEndpoint(new EndpointServlet(TestServlet.class)); + given(endpoint.getEndpointId()).willReturn(EndpointId.of("test")); + given(this.servletContext.addServlet(any(String.class), any(Servlet.class))).willReturn(this.servletDynamic); + given(this.servletContext.addFilter(any(String.class), any(Filter.class))).willReturn(this.filterDynamic); + ServletEndpointRegistrar registrar = new ServletEndpointRegistrar("/actuator", Collections.singleton(endpoint), + (endpointId, defaultAccess) -> Access.READ_ONLY); + registrar.onStartup(this.servletContext); + then(this.servletContext).should() + .addServlet(eq("test-actuator-endpoint"), + (Servlet) assertArg((servlet) -> assertThat(servlet).isInstanceOf(TestServlet.class))); + then(this.servletDynamic).should().addMapping("/actuator/test/*"); + then(this.servletContext).should() + .addFilter(eq("test-actuator-endpoint-access-filter"), (Filter) assertArg((filter) -> assertThat(filter) + .isInstanceOf( + org.springframework.boot.actuate.endpoint.web.ServletEndpointRegistrar.ReadOnlyAccessFilter.class))); + then(this.filterDynamic).should() + .addMappingForServletNames(EnumSet.allOf(DispatcherType.class), false, "test-actuator-endpoint"); } private ExposableServletEndpoint mockEndpoint(EndpointServlet endpointServlet) { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/BaseConfiguration.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/BaseConfiguration.java index 55ef50552b..f02671d1ab 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/BaseConfiguration.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/BaseConfiguration.java @@ -69,7 +69,7 @@ class BaseConfiguration { DefaultConversionService.getSharedInstance()); return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, pathMappers.orderedStream().toList(), Collections.emptyList(), Collections.emptyList(), - Collections.emptyList()); + Collections.emptyList(), Collections.emptyList()); } @Bean diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscovererTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscovererTests.java index def3749820..a6656b3862 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscovererTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscovererTests.java @@ -271,7 +271,8 @@ class WebEndpointDiscovererTests { WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(context, parameterMapper, mediaTypes, Collections.singletonList(endpointPathMapper), (additionalPathsMapper != null) ? Collections.singletonList(additionalPathsMapper) : null, - Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList()); + Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList(), + Collections.emptyList()); consumer.accept(discoverer); } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java index b140a09971..6fc0654e34 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java @@ -24,6 +24,8 @@ import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointDiscoverer; import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier; @@ -40,6 +42,7 @@ import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWeb import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -49,6 +52,7 @@ import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; import org.springframework.web.util.DefaultUriBuilderFactory; @@ -68,7 +72,7 @@ class ControllerEndpointHandlerMappingIntegrationTests { .withUserConfiguration(EndpointConfiguration.class, ExampleWebFluxEndpoint.class); @Test - void get() { + void getMapping() { this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.get() .uri("/actuator/example/one") .accept(MediaType.TEXT_PLAIN) @@ -92,7 +96,7 @@ class ControllerEndpointHandlerMappingIntegrationTests { } @Test - void post() { + void postMapping() { this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.post() .uri("/actuator/example/two") .bodyValue(Collections.singletonMap("id", "test")) @@ -103,6 +107,71 @@ class ControllerEndpointHandlerMappingIntegrationTests { .valueEquals(HttpHeaders.LOCATION, "/example/test"))); } + @Test + void postMappingWithReadOnlyAccessRespondsWith404() { + this.contextRunner.withPropertyValues("endpoint-access=READ_ONLY") + .run(withWebTestClient((webTestClient) -> webTestClient.post() + .uri("/actuator/example/two") + .bodyValue(Collections.singletonMap("id", "test")) + .exchange() + .expectStatus() + .isNotFound())); + } + + @Test + void getToRequestMapping() { + this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.get() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .isEqualTo("Three"))); + } + + @Test + void getToRequestMappingWithReadOnlyAccess() { + this.contextRunner.withPropertyValues("endpoint-access=READ_ONLY") + .run(withWebTestClient((webTestClient) -> webTestClient.get() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .isEqualTo("Three"))); + } + + @Test + void postToRequestMapping() { + this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.post() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .isEqualTo("Three"))); + } + + @Test + void postToRequestMappingWithReadOnlyAccessRespondsWith405() { + this.contextRunner.withPropertyValues("endpoint-access=READ_ONLY") + .run(withWebTestClient((webTestClient) -> webTestClient.post() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.METHOD_NOT_ALLOWED))); + } + private ContextConsumer withWebTestClient( Consumer webClient) { return (context) -> { @@ -144,9 +213,15 @@ class ControllerEndpointHandlerMappingIntegrationTests { } @Bean - ControllerEndpointHandlerMapping webEndpointHandlerMapping(ControllerEndpointsSupplier endpointsSupplier) { + ControllerEndpointHandlerMapping webEndpointHandlerMapping(ControllerEndpointsSupplier endpointsSupplier, + EndpointAccessResolver endpointAccessResolver) { return new ControllerEndpointHandlerMapping(new EndpointMapping("actuator"), - endpointsSupplier.getEndpoints(), null); + endpointsSupplier.getEndpoints(), null, endpointAccessResolver); + } + + @Bean + EndpointAccessResolver endpointAccessResolver(Environment environment) { + return (id, defaultAccess) -> environment.getProperty("endpoint-access", Access.class, Access.UNRESTRICTED); } } @@ -164,6 +239,11 @@ class ControllerEndpointHandlerMappingIntegrationTests { return ResponseEntity.created(URI.create("/example/" + content.get("id"))).build(); } + @RequestMapping(path = "/three", produces = MediaType.TEXT_PLAIN_VALUE) + String three() { + return "Three"; + } + } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java index ad1e91d5a0..51e9c83c2b 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java @@ -21,6 +21,7 @@ import java.util.Arrays; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; @@ -101,7 +102,7 @@ class ControllerEndpointHandlerMappingTests { private ControllerEndpointHandlerMapping createMapping(String prefix, ExposableControllerEndpoint... endpoints) { ControllerEndpointHandlerMapping mapping = new ControllerEndpointHandlerMapping(new EndpointMapping(prefix), - Arrays.asList(endpoints), null); + Arrays.asList(endpoints), null, (endpointId, defaultAccess) -> Access.UNRESTRICTED); mapping.setApplicationContext(this.context); mapping.afterPropertiesSet(); return mapping; diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java index 48d674573a..d9ad24f73a 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java @@ -24,6 +24,8 @@ import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.Access; +import org.springframework.boot.actuate.endpoint.EndpointAccessResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointDiscoverer; import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier; @@ -41,6 +43,7 @@ import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebSe import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -49,6 +52,7 @@ import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.util.DefaultUriBuilderFactory; /** @@ -67,7 +71,7 @@ class ControllerEndpointHandlerMappingIntegrationTests { .withUserConfiguration(EndpointConfiguration.class, ExampleMvcEndpoint.class); @Test - void get() { + void getMapping() { this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.get() .uri("/actuator/example/one") .accept(MediaType.TEXT_PLAIN) @@ -91,7 +95,7 @@ class ControllerEndpointHandlerMappingIntegrationTests { } @Test - void post() { + void postMapping() { this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.post() .uri("/actuator/example/two") .bodyValue(Collections.singletonMap("id", "test")) @@ -102,6 +106,71 @@ class ControllerEndpointHandlerMappingIntegrationTests { .valueEquals(HttpHeaders.LOCATION, "/example/test"))); } + @Test + void postMappingWithReadOnlyAccessRespondsWith404() { + this.contextRunner.withPropertyValues("endpoint-access=READ_ONLY") + .run(withWebTestClient((webTestClient) -> webTestClient.post() + .uri("/actuator/example/two") + .bodyValue(Collections.singletonMap("id", "test")) + .exchange() + .expectStatus() + .isNotFound())); + } + + @Test + void getToRequestMapping() { + this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.get() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .isEqualTo("Three"))); + } + + @Test + void getToRequestMappingWithReadOnlyAccess() { + this.contextRunner.withPropertyValues("endpoint-access=READ_ONLY") + .run(withWebTestClient((webTestClient) -> webTestClient.get() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .isEqualTo("Three"))); + } + + @Test + void postToRequestMapping() { + this.contextRunner.run(withWebTestClient((webTestClient) -> webTestClient.post() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class) + .isEqualTo("Three"))); + } + + @Test + void postToRequestMappingWithReadOnlyAccessRespondsWith405() { + this.contextRunner.withPropertyValues("endpoint-access=READ_ONLY") + .run(withWebTestClient((webTestClient) -> webTestClient.post() + .uri("/actuator/example/three") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isEqualTo(HttpStatus.METHOD_NOT_ALLOWED))); + } + private ContextConsumer withWebTestClient(Consumer webClient) { return (context) -> { int port = ((AnnotationConfigServletWebServerApplicationContext) context.getSourceApplicationContext()) @@ -137,9 +206,15 @@ class ControllerEndpointHandlerMappingIntegrationTests { } @Bean - ControllerEndpointHandlerMapping webEndpointHandlerMapping(ControllerEndpointsSupplier endpointsSupplier) { + ControllerEndpointHandlerMapping webEndpointHandlerMapping(ControllerEndpointsSupplier endpointsSupplier, + EndpointAccessResolver endpointAccessResolver) { return new ControllerEndpointHandlerMapping(new EndpointMapping("actuator"), - endpointsSupplier.getEndpoints(), null); + endpointsSupplier.getEndpoints(), null, endpointAccessResolver); + } + + @Bean + EndpointAccessResolver endpointAccessResolver(Environment environment) { + return (id, defaultAccess) -> environment.getProperty("endpoint-access", Access.class, Access.UNRESTRICTED); } } @@ -157,6 +232,11 @@ class ControllerEndpointHandlerMappingIntegrationTests { return ResponseEntity.created(URI.create("/example/" + content.get("id"))).build(); } + @RequestMapping(path = "/three", produces = MediaType.TEXT_PLAIN_VALUE) + String three() { + return "Three"; + } + } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java index 8460fa0e2d..5efcf101cc 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java @@ -20,6 +20,7 @@ import java.util.Arrays; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.Access; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; @@ -95,7 +96,7 @@ class ControllerEndpointHandlerMappingTests { private ControllerEndpointHandlerMapping createMapping(String prefix, ExposableControllerEndpoint... endpoints) { ControllerEndpointHandlerMapping mapping = new ControllerEndpointHandlerMapping(new EndpointMapping(prefix), - Arrays.asList(endpoints), null); + Arrays.asList(endpoints), null, (endpointId, defaultAccess) -> Access.UNRESTRICTED); mapping.setApplicationContext(this.context); mapping.afterPropertiesSet(); return mapping; diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java index ffc396f20e..2327785100 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java @@ -243,7 +243,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT; WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext, new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(), - Collections.emptyList(), Collections.emptyList()); + Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); Collection resources = new JerseyEndpointResourceFactory().createEndpointResources( new EndpointMapping("/actuator"), discoverer.getEndpoints(), endpointMediaTypes, new EndpointLinksResolver(discoverer.getEndpoints()), true); @@ -289,7 +289,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT; WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext, new ConversionServiceParameterValueMapper(), endpointMediaTypes, Collections.emptyList(), - Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); return new WebFluxEndpointHandlerMapping(new EndpointMapping("/actuator"), discoverer.getEndpoints(), endpointMediaTypes, new CorsConfiguration(), new EndpointLinksResolver(discoverer.getEndpoints()), true); @@ -318,7 +318,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT; WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext, new ConversionServiceParameterValueMapper(), endpointMediaTypes, Collections.emptyList(), - Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); return new WebMvcEndpointHandlerMapping(new EndpointMapping("/actuator"), discoverer.getEndpoints(), endpointMediaTypes, new CorsConfiguration(), new EndpointLinksResolver(discoverer.getEndpoints()), true); diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/ROOT/pages/redirect.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/ROOT/pages/redirect.adoc index 32924b00ac..1ff13213b3 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/ROOT/pages/redirect.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/ROOT/pages/redirect.adoc @@ -737,8 +737,8 @@ * xref:reference:actuator/enabling.adoc#actuator.enabling[#actuator.enabling] * xref:reference:actuator/endpoints.adoc#actuator.endpoints[#actuator.endpoints] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.caching[#actuator.endpoints.caching] +* xref:reference:actuator/endpoints.adoc#actuator.endpoints.controlling-access[#actuator.endpoints.enabling] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.cors[#actuator.endpoints.cors] -* xref:reference:actuator/endpoints.adoc#actuator.endpoints.enabling[#actuator.endpoints.enabling] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.exposing[#actuator.endpoints.exposing] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.health[#actuator.endpoints.health] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.health.auto-configured-health-indicators[#actuator.endpoints.health.auto-configured-health-indicators] @@ -773,6 +773,7 @@ * xref:reference:actuator/endpoints.adoc#actuator.endpoints.kubernetes-probes.external-state[#actuator.endpoints.kubernetes-probes.external-state] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.kubernetes-probes.lifecycle[#actuator.endpoints.kubernetes-probes.lifecycle] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.sanitization[#howto-sanitize-sensitive-values] +* xref:reference:actuator/endpoints.adoc#actuator.endpoints.controlling-access[#actuator.endpoints.enabling] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.sanitization[#actuator.endpoints.sanitization] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.sanitization[#howto-sanitize-sensible-values] * xref:reference:actuator/endpoints.adoc#actuator.endpoints.sbom[#actuator.endpoints.sbom] diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc index 0d9604e07d..f109f33ca4 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc @@ -5,8 +5,8 @@ Actuator endpoints let you monitor and interact with your application. Spring Boot includes a number of built-in endpoints and lets you add your own. For example, the `health` endpoint provides basic application health information. -You can xref:actuator/endpoints.adoc#actuator.endpoints.enabling[enable or disable] each individual endpoint and xref:actuator/endpoints.adoc#actuator.endpoints.exposing[expose them (make them remotely accessible) over HTTP or JMX]. -An endpoint is considered to be available when it is both enabled and exposed. +You can xref:actuator/endpoints.adoc#actuator.endpoints.controlling-access[control access] to each individual endpoint and xref:actuator/endpoints.adoc#actuator.endpoints.exposing[expose them (make them remotely accessible) over HTTP or JMX]. +An endpoint is considered to be available when access to it is permitted and it is exposed. The built-in endpoints are auto-configured only when they are available. Most applications choose exposure over HTTP, where the ID of the endpoint and a prefix of `/actuator` is mapped to a URL. For example, by default, the `health` endpoint is mapped to `/actuator/health`. @@ -117,38 +117,52 @@ If your application is a web application (Spring MVC, Spring WebFlux, or Jersey) -[[actuator.endpoints.enabling]] -== Enabling Endpoints +[[actuator.endpoints.controlling-access]] +== Controlling Access to Endpoints -By default, all endpoints except for `shutdown` are enabled. -To configure the enablement of an endpoint, use its `management.endpoint..enabled` property. -The following example enables the `shutdown` endpoint: +By default, access to all endpoints except for `shutdown` is unrestricted. +To configure the permitted access to an endpoint, use its `management.endpoint..access` property. +The following example allows unrestricted access to the `shutdown` endpoint: [configprops,yaml] ---- management: endpoint: shutdown: - enabled: true + access: unrestricted ---- -If you prefer endpoint enablement to be opt-in rather than opt-out, set the configprop:management.endpoints.enabled-by-default[] property to `false` and use individual endpoint `enabled` properties to opt back in. -The following example enables the `info` endpoint and disables all other endpoints: +If you prefer access to be opt-in rather than opt-out, set the configprop:management.endpoints.access.default[] property to `disabled` and use individual endpoint `access` properties to opt back in. +The following example allows read-only access to the `loggers` endpoint and disables all other endpoints: [configprops,yaml] ---- management: endpoints: - enabled-by-default: false + access: + default: disabled endpoint: - info: - enabled: true + loggers: + access: read-only ---- -NOTE: Disabled endpoints are removed entirely from the application context. +NOTE: Inaccessible endpoints are removed entirely from the application context. If you want to change only the technologies over which an endpoint is exposed, use the xref:actuator/endpoints.adoc#actuator.endpoints.exposing[`include` and `exclude` properties] instead. +[[actuator.endpoints.controlling-access.limiting]] +=== Limiting Access + +Application-wide endpoint access can be limited using the configprop:management.endpoints.access.max-permitted[] property. +This property takes precedence over the default access or an individual endpoint's access level. +Set it to `none` to make all endpoints inaccessible. +Set it to `read-only` to only allow read access to endpoints. + +For `@Endpoint`, `@JmxEndpoint`, and `@WebEndpoint`, read access equates to the endpoint methods annotated with `@ReadEndpoint`. +For `@ControllerEndpoint` and `@RestControllerEndpoint`, read access equates to request mappings that can handle `GET` and `HEAD` requests. +For `@ServletEndpoint`, read access equates to `GET` and `HEAD` requests. + + [[actuator.endpoints.exposing]] == Exposing Endpoints diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoTestExecutionListenerIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoTestExecutionListenerIntegrationTests.java index fea91da20a..7804ffdecd 100644 --- a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoTestExecutionListenerIntegrationTests.java +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/mock/mockito/MockitoTestExecutionListenerIntegrationTests.java @@ -236,6 +236,7 @@ class MockitoTestExecutionListenerIntegrationTests { @Nested @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestInstance(Lifecycle.PER_CLASS) + @Disabled("https://github.com/spring-projects/spring-framework/issues/33690") class ConfigureMockInBeforeAll { @Mock diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java index ba88d48aab..9d6ee1866f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java @@ -24,7 +24,9 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -45,6 +47,7 @@ import javax.tools.Diagnostic.Kind; import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; import org.springframework.boot.configurationprocessor.metadata.InvalidConfigurationMetadataException; +import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; /** @@ -104,6 +107,8 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor static final String NAME_ANNOTATION = "org.springframework.boot.context.properties.bind.Name"; + static final String ENDPOINT_ACCESS_ENUM = "org.springframework.boot.actuate.endpoint.Access"; + private static final Set SUPPORTED_OPTIONS = Set.of(ADDITIONAL_METADATA_LOCATIONS_OPTION); private MetadataStore metadataStore; @@ -149,6 +154,10 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor return NAME_ANNOTATION; } + protected String endpointAccessEnum() { + return ENDPOINT_ACCESS_ENUM; + } + @Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); @@ -291,13 +300,21 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor return; // Can't process that endpoint } String endpointKey = ItemMetadata.newItemMetadataPrefix("management.endpoint.", endpointId); - boolean enabledByDefault = (boolean) elementValues.getOrDefault("enableByDefault", true); + boolean enabledByDefaultAttribute = (boolean) elementValues.getOrDefault("enableByDefault", true); + String defaultAccess = (!enabledByDefaultAttribute) ? "none" + : (elementValues.getOrDefault("defaultAccess", "unrestricted").toString()).toLowerCase(Locale.ENGLISH); + boolean enabledByDefault = "none".equals(defaultAccess) ? false : enabledByDefaultAttribute; String type = this.metadataEnv.getTypeUtils().getQualifiedName(element); this.metadataCollector.addIfAbsent(ItemMetadata.newGroup(endpointKey, type, type, null)); + ItemMetadata accessProperty = ItemMetadata.newProperty(endpointKey, "access", endpointAccessEnum(), type, null, + "Permitted level of access for the %s endpoint.".formatted(endpointId), defaultAccess, null); this.metadataCollector.add( ItemMetadata.newProperty(endpointKey, "enabled", Boolean.class.getName(), type, null, - "Whether to enable the %s endpoint.".formatted(endpointId), enabledByDefault, null), + "Whether to enable the %s endpoint.".formatted(endpointId), enabledByDefault, + new ItemDeprecation(null, accessProperty.getName(), "3.4.0")), (existing) -> checkEnabledValueMatchesExisting(existing, enabledByDefault, type)); + this.metadataCollector.add(accessProperty, + (existing) -> checkDefaultAccessValueMatchesExisting(existing, defaultAccess, type)); if (hasMainReadOperation(element)) { this.metadataCollector.addIfAbsent(ItemMetadata.newProperty(endpointKey, "cache.time-to-live", Duration.class.getName(), type, null, "Maximum time that a response can be cached.", "0ms", null)); @@ -314,6 +331,17 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor } } + private void checkDefaultAccessValueMatchesExisting(ItemMetadata existing, String defaultAccess, + String sourceType) { + String existingDefaultAccess = (String) existing.getDefaultValue(); + if (!Objects.equals(defaultAccess, existingDefaultAccess)) { + throw new IllegalStateException( + "Existing property '%s' from type %s has a conflicting value. Existing value: %b, new value from type %s: %b" + .formatted(existing.getName(), existing.getSourceType(), existingDefaultAccess, sourceType, + defaultAccess)); + } + } + private boolean hasMainReadOperation(TypeElement element) { for (ExecutableElement method : ElementFilter.methodsIn(element.getEnclosedElements())) { if (this.metadataEnv.getReadOperationAnnotation(method) != null diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java index e9d88b28ba..76c64f8627 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/EndpointMetadataGenerationTests.java @@ -17,19 +17,24 @@ package org.springframework.boot.configurationprocessor; import java.time.Duration; +import java.util.Locale; import org.junit.jupiter.api.Test; import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; import org.springframework.boot.configurationprocessor.metadata.Metadata; +import org.springframework.boot.configurationsample.Access; import org.springframework.boot.configurationsample.endpoint.CamelCaseEndpoint; import org.springframework.boot.configurationsample.endpoint.CustomPropertiesEndpoint; import org.springframework.boot.configurationsample.endpoint.DisabledEndpoint; import org.springframework.boot.configurationsample.endpoint.EnabledEndpoint; +import org.springframework.boot.configurationsample.endpoint.NoAccessEndpoint; +import org.springframework.boot.configurationsample.endpoint.ReadOnlyAccessEndpoint; import org.springframework.boot.configurationsample.endpoint.SimpleEndpoint; import org.springframework.boot.configurationsample.endpoint.SimpleEndpoint2; import org.springframework.boot.configurationsample.endpoint.SimpleEndpoint3; import org.springframework.boot.configurationsample.endpoint.SpecificEndpoint; +import org.springframework.boot.configurationsample.endpoint.UnrestrictedAccessEndpoint; import org.springframework.boot.configurationsample.endpoint.incremental.IncrementalEndpoint; import static org.assertj.core.api.Assertions.assertThat; @@ -49,16 +54,18 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { ConfigurationMetadata metadata = compile(SimpleEndpoint.class); assertThat(metadata).has(Metadata.withGroup("management.endpoint.simple").fromSource(SimpleEndpoint.class)); assertThat(metadata).has(enabledFlag("simple", true)); + assertThat(metadata).has(access("simple", Access.UNRESTRICTED)); assertThat(metadata).has(cacheTtl("simple")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); } @Test - void disableEndpoint() { + void disabledEndpoint() { ConfigurationMetadata metadata = compile(DisabledEndpoint.class); assertThat(metadata).has(Metadata.withGroup("management.endpoint.disabled").fromSource(DisabledEndpoint.class)); assertThat(metadata).has(enabledFlag("disabled", false)); - assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata).has(access("disabled", Access.NONE)); + assertThat(metadata.getItems()).hasSize(3); } @Test @@ -66,7 +73,37 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { ConfigurationMetadata metadata = compile(EnabledEndpoint.class); assertThat(metadata).has(Metadata.withGroup("management.endpoint.enabled").fromSource(EnabledEndpoint.class)); assertThat(metadata).has(enabledFlag("enabled", true)); - assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata).has(access("enabled", Access.UNRESTRICTED)); + assertThat(metadata.getItems()).hasSize(3); + } + + @Test + void noAccessEndpoint() { + ConfigurationMetadata metadata = compile(NoAccessEndpoint.class); + assertThat(metadata).has(Metadata.withGroup("management.endpoint.noaccess").fromSource(NoAccessEndpoint.class)); + assertThat(metadata).has(enabledFlag("noaccess", false)); + assertThat(metadata).has(access("noaccess", Access.NONE)); + assertThat(metadata.getItems()).hasSize(3); + } + + @Test + void readOnlyAccessEndpoint() { + ConfigurationMetadata metadata = compile(ReadOnlyAccessEndpoint.class); + assertThat(metadata) + .has(Metadata.withGroup("management.endpoint.readonlyaccess").fromSource(ReadOnlyAccessEndpoint.class)); + assertThat(metadata).has(enabledFlag("readonlyaccess", true)); + assertThat(metadata).has(access("readonlyaccess", Access.READ_ONLY)); + assertThat(metadata.getItems()).hasSize(3); + } + + @Test + void unrestrictedAccessEndpoint() { + ConfigurationMetadata metadata = compile(UnrestrictedAccessEndpoint.class); + assertThat(metadata).has(Metadata.withGroup("management.endpoint.unrestrictedaccess") + .fromSource(UnrestrictedAccessEndpoint.class)); + assertThat(metadata).has(enabledFlag("unrestrictedaccess", true)); + assertThat(metadata).has(access("unrestrictedaccess", Access.UNRESTRICTED)); + assertThat(metadata.getItems()).hasSize(3); } @Test @@ -78,8 +115,9 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { .ofType(String.class) .withDefaultValue("test")); assertThat(metadata).has(enabledFlag("customprops", true)); + assertThat(metadata).has(access("customprops", Access.UNRESTRICTED)); assertThat(metadata).has(cacheTtl("customprops")); - assertThat(metadata.getItems()).hasSize(4); + assertThat(metadata.getItems()).hasSize(5); } @Test @@ -87,8 +125,9 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { ConfigurationMetadata metadata = compile(SpecificEndpoint.class); assertThat(metadata).has(Metadata.withGroup("management.endpoint.specific").fromSource(SpecificEndpoint.class)); assertThat(metadata).has(enabledFlag("specific", true)); + assertThat(metadata).has(access("specific", Access.UNRESTRICTED)); assertThat(metadata).has(cacheTtl("specific")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); } @Test @@ -97,7 +136,8 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { assertThat(metadata) .has(Metadata.withGroup("management.endpoint.pascal-case").fromSource(CamelCaseEndpoint.class)); assertThat(metadata).has(enabledFlag("PascalCase", "pascal-case", true)); - assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata).has(defaultAccess("PascalCase", "pascal-case", Access.UNRESTRICTED)); + assertThat(metadata.getItems()).hasSize(3); } @Test @@ -107,16 +147,18 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { assertThat(metadata) .has(Metadata.withGroup("management.endpoint.incremental").fromSource(IncrementalEndpoint.class)); assertThat(metadata).has(enabledFlag("incremental", true)); + assertThat(metadata).has(access("incremental", Access.UNRESTRICTED)); assertThat(metadata).has(cacheTtl("incremental")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); project.replaceText(IncrementalEndpoint.class, "id = \"incremental\"", "id = \"incremental\", enableByDefault = false"); metadata = project.compile(); assertThat(metadata) .has(Metadata.withGroup("management.endpoint.incremental").fromSource(IncrementalEndpoint.class)); assertThat(metadata).has(enabledFlag("incremental", false)); + assertThat(metadata).has(access("incremental", Access.NONE)); assertThat(metadata).has(cacheTtl("incremental")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); } @Test @@ -126,14 +168,16 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { assertThat(metadata) .has(Metadata.withGroup("management.endpoint.incremental").fromSource(IncrementalEndpoint.class)); assertThat(metadata).has(enabledFlag("incremental", true)); + assertThat(metadata).has(access("incremental", Access.UNRESTRICTED)); assertThat(metadata).has(cacheTtl("incremental")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); project.replaceText(IncrementalEndpoint.class, "@Nullable String param", "String param"); metadata = project.compile(); assertThat(metadata) .has(Metadata.withGroup("management.endpoint.incremental").fromSource(IncrementalEndpoint.class)); assertThat(metadata).has(enabledFlag("incremental", true)); - assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata).has(access("incremental", Access.UNRESTRICTED)); + assertThat(metadata.getItems()).hasSize(3); } @Test @@ -142,14 +186,16 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { ConfigurationMetadata metadata = project.compile(); assertThat(metadata).has(Metadata.withGroup("management.endpoint.specific").fromSource(SpecificEndpoint.class)); assertThat(metadata).has(enabledFlag("specific", true)); + assertThat(metadata).has(access("specific", Access.UNRESTRICTED)); assertThat(metadata).has(cacheTtl("specific")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); project.replaceText(SpecificEndpoint.class, "enableByDefault = true", "enableByDefault = false"); metadata = project.compile(); assertThat(metadata).has(Metadata.withGroup("management.endpoint.specific").fromSource(SpecificEndpoint.class)); assertThat(metadata).has(enabledFlag("specific", false)); + assertThat(metadata).has(access("specific", Access.NONE)); assertThat(metadata).has(cacheTtl("specific")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); } @Test @@ -157,8 +203,9 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { ConfigurationMetadata metadata = compile(SimpleEndpoint.class, SimpleEndpoint2.class); assertThat(metadata).has(Metadata.withGroup("management.endpoint.simple").fromSource(SimpleEndpoint.class)); assertThat(metadata).has(enabledFlag("simple", "simple", true)); + assertThat(metadata).has(defaultAccess("simple", "simple", Access.UNRESTRICTED)); assertThat(metadata).has(cacheTtl("simple")); - assertThat(metadata.getItems()).hasSize(3); + assertThat(metadata.getItems()).hasSize(4); } @Test @@ -170,14 +217,26 @@ class EndpointMetadataGenerationTests extends AbstractMetadataGenerationTests { "Existing property 'management.endpoint.simple.enabled' from type org.springframework.boot.configurationsample.endpoint.SimpleEndpoint has a conflicting value. Existing value: true, new value from type org.springframework.boot.configurationsample.endpoint.SimpleEndpoint3: false"); } + private Metadata.MetadataItemCondition enabledFlag(String endpointId, Boolean defaultValue) { + return enabledFlag(endpointId, endpointId, defaultValue); + } + private Metadata.MetadataItemCondition enabledFlag(String endpointId, String endpointSuffix, Boolean defaultValue) { return Metadata.withEnabledFlag("management.endpoint." + endpointSuffix + ".enabled") .withDefaultValue(defaultValue) - .withDescription(String.format("Whether to enable the %s endpoint.", endpointId)); + .withDescription(String.format("Whether to enable the %s endpoint.", endpointId)) + .withDeprecation(null, "management.endpoint.%s.access".formatted(endpointSuffix), "3.4.0"); } - private Metadata.MetadataItemCondition enabledFlag(String endpointId, Boolean defaultValue) { - return enabledFlag(endpointId, endpointId, defaultValue); + private Metadata.MetadataItemCondition access(String endpointId, Access defaultValue) { + return defaultAccess(endpointId, endpointId, defaultValue); + } + + private Metadata.MetadataItemCondition defaultAccess(String endpointId, String endpointSuffix, + Access defaultValue) { + return Metadata.withAccess("management.endpoint." + endpointSuffix + ".access") + .withDefaultValue(defaultValue.name().toLowerCase(Locale.ENGLISH)) + .withDescription("Permitted level of access for the %s endpoint.".formatted(endpointId)); } private Metadata.MetadataItemCondition cacheTtl(String endpointId) { diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java index 0f4fcb10fd..effe586565 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/Metadata.java @@ -25,6 +25,7 @@ import org.assertj.core.api.Condition; import org.hamcrest.collection.IsMapContaining; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata.ItemType; +import org.springframework.boot.configurationsample.Access; import org.springframework.util.ObjectUtils; /** @@ -66,6 +67,10 @@ public final class Metadata { return withProperty(key).ofType(Boolean.class); } + public static Metadata.MetadataItemCondition withAccess(String key) { + return withProperty(key).ofType(Access.class); + } + public static MetadataHintCondition withHint(String name) { return new MetadataHintCondition(name); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/test/TestConfigurationMetadataAnnotationProcessor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/test/TestConfigurationMetadataAnnotationProcessor.java index cb0cbd4884..ebb84efb5f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/test/TestConfigurationMetadataAnnotationProcessor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/test/TestConfigurationMetadataAnnotationProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2022 the original author or authors. + * Copyright 2012-2024 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. @@ -74,6 +74,8 @@ public class TestConfigurationMetadataAnnotationProcessor extends ConfigurationM public static final String NAME_ANNOTATION = "org.springframework.boot.configurationsample.Name"; + public static final String ENDPOINT_ACCESS_ENUM = "org.springframework.boot.configurationsample.Access"; + public TestConfigurationMetadataAnnotationProcessor() { } @@ -123,4 +125,9 @@ public class TestConfigurationMetadataAnnotationProcessor extends ConfigurationM return NAME_ANNOTATION; } + @Override + protected String endpointAccessEnum() { + return ENDPOINT_ACCESS_ENUM; + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/Access.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/Access.java new file mode 100644 index 0000000000..8ed9bf2d50 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/Access.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.configurationsample; + +/** + * Permitted level of access to an endpoint. + * + * @author Andy Wilkinson + */ +public enum Access { + + NONE, + + READ_ONLY, + + UNRESTRICTED + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ControllerEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ControllerEndpoint.java index 5f055fbe02..5e5b0ad5dc 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ControllerEndpoint.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ControllerEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 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. @@ -35,6 +35,9 @@ public @interface ControllerEndpoint { String id() default ""; + @Deprecated boolean enableByDefault() default true; + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/Endpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/Endpoint.java index b28050d521..44cc2d9a01 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/Endpoint.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/Endpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2024 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. @@ -35,6 +35,9 @@ public @interface Endpoint { String id() default ""; + @Deprecated boolean enableByDefault() default true; + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/JmxEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/JmxEndpoint.java index a1e62e6321..eba43570eb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/JmxEndpoint.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/JmxEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 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. @@ -35,6 +35,9 @@ public @interface JmxEndpoint { String id() default ""; + @Deprecated boolean enableByDefault() default true; + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/RestControllerEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/RestControllerEndpoint.java index 283b90e567..0643a67e1d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/RestControllerEndpoint.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/RestControllerEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 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. @@ -35,6 +35,9 @@ public @interface RestControllerEndpoint { String id() default ""; + @Deprecated boolean enableByDefault() default true; + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ServletEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ServletEndpoint.java index fecf8b08a3..a3c0ccaf7e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ServletEndpoint.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ServletEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 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. @@ -35,6 +35,9 @@ public @interface ServletEndpoint { String id() default ""; + @Deprecated boolean enableByDefault() default true; + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/WebEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/WebEndpoint.java index 48b3ee4091..f40415058f 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/WebEndpoint.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/WebEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2024 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. @@ -35,6 +35,9 @@ public @interface WebEndpoint { String id() default ""; + @Deprecated boolean enableByDefault() default true; + Access defaultAccess() default Access.UNRESTRICTED; + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/NoAccessEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/NoAccessEndpoint.java new file mode 100644 index 0000000000..bbfa4a9768 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/NoAccessEndpoint.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.configurationsample.endpoint; + +import org.springframework.boot.configurationsample.Access; +import org.springframework.boot.configurationsample.Endpoint; + +/** + * An endpoint with no permitted access unless configured explicitly. + * + * @author Andy Wilkinson + */ +@Endpoint(id = "noaccess", defaultAccess = Access.NONE) +public class NoAccessEndpoint { + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/ReadOnlyAccessEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/ReadOnlyAccessEndpoint.java new file mode 100644 index 0000000000..d3ec67d14f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/ReadOnlyAccessEndpoint.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.configurationsample.endpoint; + +import org.springframework.boot.configurationsample.Access; +import org.springframework.boot.configurationsample.Endpoint; + +/** + * An endpoint with read-only access unless configured explicitly. + * + * @author Andy Wilkinson + */ +@Endpoint(id = "readonlyaccess", defaultAccess = Access.READ_ONLY) +public class ReadOnlyAccessEndpoint { + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/UnrestrictedAccessEndpoint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/UnrestrictedAccessEndpoint.java new file mode 100644 index 0000000000..7267e18957 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/endpoint/UnrestrictedAccessEndpoint.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2024 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 + * + * https://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.configurationsample.endpoint; + +import org.springframework.boot.configurationsample.Endpoint; + +/** + * An endpoint with unrestricted access unless configured explicitly. + * + * @author Andy Wilkinson + */ +@Endpoint(id = "unrestrictedaccess") +public class UnrestrictedAccessEndpoint { + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-extension/src/main/java/smoketest/actuator/extension/MyExtensionConfiguration.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-extension/src/main/java/smoketest/actuator/extension/MyExtensionConfiguration.java index bec987b68e..196a71027c 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-extension/src/main/java/smoketest/actuator/extension/MyExtensionConfiguration.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-actuator-extension/src/main/java/smoketest/actuator/extension/MyExtensionConfiguration.java @@ -50,7 +50,7 @@ public class MyExtensionConfiguration { List> filters = Collections .singletonList(new MyExtensionEndpointFilter(environment)); WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(applicationContext, parameterMapper, - endpointMediaTypes, null, null, invokerAdvisors, filters); + endpointMediaTypes, null, null, invokerAdvisors, filters, Collections.emptyList()); Collection endpoints = discoverer.getEndpoints(); return new MyExtensionWebMvcEndpointHandlerMapping(endpoints, endpointMediaTypes, corsConfiguration); }