Provide more control over access to endpoint operations

This commit reworks the support for enabling and disabling endpoints,
replacing the on/off support that it provided with a finer-grained
access model that supports only allowing read-only access to endpoint
operations in addition to disabling an endpoint (access of none) and
fully enabling it (access of unrestricted).

The following properties are deprecated:

- management.endpoints.enabled-by-default
- management.endpoint.<id>.enabled

Their replacements are:

- management.endpoints.access.default
- management.endpoint.<id>.access

Similarly, the enableByDefault attribute on @Endpoint has been
deprecated with a new defaultAccess attribute replacing it.

Additionally, a new property has been introduced that allows an
operator to control the level of access to Actuator endpoints
that is permitted:

- management.endpoints.access.max-permitted

This property caps any access that may has been configured for
an endpoint. For example, if
management.endpoints.access.max-permitted is set to read-only and
management.endpoint.loggers.access is set to unrestricted, only
read-only access to the loggers endpoint will be allowed.

Closes gh-39046
This commit is contained in:
Andy Wilkinson
2024-10-08 14:12:04 +01:00
parent 4ce91417a7
commit 25082d33e7
84 changed files with 2568 additions and 215 deletions

View File

@@ -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<PathMapper> endpointPathMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> 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<PathMapper> endpointPathMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
Collection<EndpointFilter<ExposableWebEndpoint>> endpointFilters,
Collection<OperationFilter<WebOperation>> operationFilters) {
super(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, null, invokerAdvisors,
filters);
endpointFilters, operationFilters);
}
@Override

View File

@@ -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<ExposableWebEndpoint> webEndpoints = endpointDiscoverer.getEndpoints();

View File

@@ -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<ExposableWebEndpoint> webEndpoints = discoverer.getEndpoints();

View File

@@ -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 <O> the operation type
* @author Andy Wilkinson
* @since 3.4.0
*/
public class EndpointAccessOperationFilter<O extends Operation> implements OperationFilter<O> {
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;
};
}
}

View File

@@ -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);
}
}

View File

@@ -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:
* <ol>
* <li>{@code management.endpoint.<id>.access} or {@code management.endpoint.<id>.enabled}
* (deprecated)
* <li>{@code management.endpoints.access.default} or
* {@code management.endpoints.enabled-by-default} (deprecated)
* </ol>
* 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<EndpointId, Access> 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())];
}
}

View File

@@ -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<Environment, EndpointAccessResolver> accessResolversCache = new ConcurrentReferenceHashMap<>();
private static final Map<Environment, Set<EndpointExposureOutcomeContributor>> exposureOutcomeContributorsCache = new ConcurrentReferenceHashMap<>();
private static final ConcurrentReferenceHashMap<Environment, Optional<Boolean>> enabledByDefaultCache = new ConcurrentReferenceHashMap<>();
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
Environment environment = context.getEnvironment();
@@ -114,35 +114,27 @@ class OnAvailableEndpointCondition extends SpringBootCondition {
MergedAnnotation<Endpoint> 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<Endpoint> 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<Endpoint> 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<Boolean> 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,

View File

@@ -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<OperationInvokerAdvisor> invokerAdvisors,
ObjectProvider<EndpointFilter<ExposableJmxEndpoint>> filters) {
ObjectProvider<EndpointFilter<ExposableJmxEndpoint>> endpointFilters,
ObjectProvider<OperationFilter<JmxOperation>> 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<JmxOperation> jmxAccessPropertiesOperationFilter(
EndpointAccessResolver endpointAccessResolver) {
return new EndpointAccessOperationFilter<>(endpointAccessResolver);
}
}

View File

@@ -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);
}
}

View File

@@ -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<PathMapper> endpointPathMappers,
ObjectProvider<AdditionalPathsMapper> additionalPathsMappers,
ObjectProvider<OperationInvokerAdvisor> invokerAdvisors,
ObjectProvider<EndpointFilter<ExposableWebEndpoint>> filters) {
ObjectProvider<EndpointFilter<ExposableWebEndpoint>> endpointFilters,
ObjectProvider<OperationFilter<WebOperation>> 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<WebOperation> webAccessPropertiesOperationFilter(
EndpointAccessResolver endpointAccessResolver) {
return new EndpointAccessOperationFilter<>(endpointAccessResolver);
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
static class WebEndpointServletConfiguration {

View File

@@ -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

View File

@@ -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

View File

@@ -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.<id>.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",

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<Operation> 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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";
}
}
}

View File

@@ -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

View File

@@ -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() {

View File

@@ -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<byte[]> 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<org.springframework.boot.actuate.endpoint.web.EndpointServlet> {
@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 {
}
});
}
}
}

View File

@@ -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";
}
}
}

View File

@@ -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<byte[]> 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";
}
}
}

View File

@@ -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<byte[]> 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<org.springframework.boot.actuate.endpoint.web.EndpointServlet> {
@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 {
}
});
}
}
}

View File

@@ -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;

View File

@@ -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<O extends Operation> implements
private final EndpointId id;
private final boolean enabledByDefault;
private final Access defaultAccess;
private final List<O> operations;
@@ -41,12 +41,26 @@ public abstract class AbstractExposableEndpoint<O extends Operation> 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<? extends O> 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<? extends O> 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<O extends Operation> 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

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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<O extends Operation> {
/**
* 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

View File

@@ -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 <O> the operation type
* @author Andy Wilkinson
* @since 3.4.0
*/
@FunctionalInterface
public interface OperationFilter<O extends Operation> {
/**
* 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);
}

View File

@@ -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<O extends Operation> 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<? extends O> 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<? extends O> operations) {
super(id, defaultAccess, operations);
Assert.notNull(discoverer, "Discoverer must not be null");
Assert.notNull(endpointBean, "EndpointBean must not be null");
this.discoverer = discoverer;

View File

@@ -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;
}

View File

@@ -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<E extends ExposableEndpoint<O>, O exten
private final ApplicationContext applicationContext;
private final Collection<EndpointFilter<E>> filters;
private final Collection<EndpointFilter<E>> endpointFilters;
private final Collection<OperationFilter<O>> operationFilters;
private final DiscoveredOperationsFactory<O> operationsFactory;
@@ -85,16 +89,36 @@ public abstract class EndpointDiscoverer<E extends ExposableEndpoint<O>, 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<OperationInvokerAdvisor> invokerAdvisors, Collection<EndpointFilter<E>> 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<OperationInvokerAdvisor> invokerAdvisors, Collection<EndpointFilter<E>> filters) {
Collection<OperationInvokerAdvisor> invokerAdvisors, Collection<EndpointFilter<E>> endpointFilters,
Collection<OperationFilter<O>> 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<E extends ExposableEndpoint<O>, O exten
Collection<OperationInvokerAdvisor> invokerAdvisors) {
return new DiscoveredOperationsFactory<>(parameterValueMapper, invokerAdvisors) {
@Override
Collection<O> 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<E extends ExposableEndpoint<O>, O exten
Set<E> 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<OperationKey, O> 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<E extends ExposableEndpoint<O>, 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<O> 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<OperationKey, O> indexed, EndpointId id, Object target,
boolean replaceLast) {
private void addOperations(MultiValueMap<OperationKey, O> indexed, EndpointId id, Access defaultAccess,
Object target, boolean replaceLast) {
Set<OperationKey> replacedLast = new HashSet<>();
Collection<O> 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<E extends ExposableEndpoint<O>, O exten
}
private boolean isEndpointFiltered(EndpointBean endpointBean) {
for (EndpointFilter<E> filter : this.filters) {
for (EndpointFilter<E> filter : this.endpointFilters) {
if (!isFilterMatch(filter, endpointBean)) {
return true;
}
@@ -307,14 +353,27 @@ public abstract class EndpointDiscoverer<E extends ExposableEndpoint<O>, 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<O> filter : this.operationFilters) {
if (!isFilterMatch(filter, operation, endpointId, defaultAccess)) {
return true;
}
}
return endpoint;
return false;
}
@SuppressWarnings("unchecked")
private boolean isFilterMatch(OperationFilter<O> 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<E extends ExposableEndpoint<O>, 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<O> 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<O> operations);
/**
@@ -408,7 +482,7 @@ public abstract class EndpointDiscoverer<E extends ExposableEndpoint<O>, 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<E extends ExposableEndpoint<O>, 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<E extends ExposableEndpoint<O>, O exten
return this.id;
}
boolean isEnabledByDefault() {
return this.enabledByDefault;
Access getDefaultAccess() {
return this.defaultAccess;
}
Class<?> getFilter() {

View File

@@ -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<ObjectName> 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) {

View File

@@ -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<JmxOperation> implements ExposableJmxEndpoint {
DiscoveredJmxEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id,
boolean enabledByDefault, Collection<JmxOperation> operations) {
super(discoverer, endpointBean, id, enabledByDefault, operations);
@SuppressWarnings("removal")
DiscoveredJmxEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id, Access defaultAccess,
Collection<JmxOperation> operations) {
super(discoverer, endpointBean, id, defaultAccess, operations);
}
}

View File

@@ -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;
}

View File

@@ -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<ExposableJmxEndpoi
* @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 #JmxEndpointDiscoverer(ApplicationContext, ParameterValueMapper, Collection, Collection, Collection)}
*/
@Deprecated(since = "3.4.0", forRemoval = true)
public JmxEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableJmxEndpoint>> 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<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableJmxEndpoint>> filters) {
super(applicationContext, parameterValueMapper, invokerAdvisors, filters);
Collection<EndpointFilter<ExposableJmxEndpoint>> endpointFilters,
Collection<OperationFilter<JmxOperation>> 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<JmxOperation> operations) {
return new DiscoveredJmxEndpoint(this, endpointBean, id, enabledByDefault, operations);
return new DiscoveredJmxEndpoint(this, endpointBean, id, defaultAccess, operations);
}
@Override

View File

@@ -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<String> 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<ExposableServletEndpoint> servletEndpoints;
private final EndpointAccessResolver endpointAccessResolver;
public ServletEndpointRegistrar(String basePath, Collection<ExposableServletEndpoint> servletEndpoints) {
this(basePath, servletEndpoints, (endpointId, defaultAccess) -> Access.NONE);
}
public ServletEndpointRegistrar(String basePath, Collection<ExposableServletEndpoint> 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();
}
}
}
}

View File

@@ -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;
}

View File

@@ -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<ExposableCo
*/
public ControllerEndpointDiscoverer(ApplicationContext applicationContext, List<PathMapper> endpointPathMappers,
Collection<EndpointFilter<ExposableControllerEndpoint>> 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<ExposableCo
}
@Override
protected ExposableControllerEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault,
protected ExposableControllerEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess,
Collection<Operation> 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<ExposableCo
throw new IllegalStateException("ControllerEndpoints must not declare operations");
}
@Override
protected boolean isInvocable(ExposableControllerEndpoint endpoint) {
return true;
}
static class ControllerEndpointDiscovererRuntimeHints implements RuntimeHintsRegistrar {
@Override

View File

@@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.web.annotation;
import java.util.Collections;
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;
@@ -35,8 +36,8 @@ class DiscoveredControllerEndpoint extends AbstractDiscoveredEndpoint<Operation>
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;
}

View File

@@ -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<Operation> 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");

View File

@@ -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<WebOperation> imp
private Collection<AdditionalPathsMapper> additionalPathsMappers;
DiscoveredWebEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id, String rootPath,
boolean enabledByDefault, Collection<WebOperation> operations,
Access defaultAccess, Collection<WebOperation> operations,
Collection<AdditionalPathsMapper> additionalPathsMappers) {
super(discoverer, endpointBean, id, enabledByDefault, operations);
super(discoverer, endpointBean, id, defaultAccess, operations);
this.rootPath = rootPath;
this.additionalPathsMappers = additionalPathsMappers;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<ExposableServl
*/
public ServletEndpointDiscoverer(ApplicationContext applicationContext, List<PathMapper> endpointPathMappers,
Collection<EndpointFilter<ExposableServletEndpoint>> 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<ExposableServl
}
@Override
protected ExposableServletEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault,
protected ExposableServletEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess,
Collection<Operation> 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<ExposableServl
throw new IllegalStateException("ServletEndpoints must not declare operations");
}
@Override
protected boolean isInvocable(ExposableServletEndpoint endpoint) {
return true;
}
static class ServletEndpointDiscovererRuntimeHints implements RuntimeHintsRegistrar {
@Override

View File

@@ -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 WebEndpoint {
/**
* 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;
}

View File

@@ -23,8 +23,10 @@ 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.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;
@@ -66,7 +68,7 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
* @param invokerAdvisors invoker advisors to apply
* @param filters filters to apply
* @deprecated since 3.4.0 for removal in 3.6.0 in favor of
* {@link #WebEndpointDiscoverer(ApplicationContext, ParameterValueMapper, EndpointMediaTypes, List, List, Collection, Collection)}
* {@link #WebEndpointDiscoverer(ApplicationContext, ParameterValueMapper, EndpointMediaTypes, List, List, Collection, Collection, Collection)}
*/
@Deprecated(since = "3.4.0", forRemoval = true)
public WebEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
@@ -74,7 +76,7 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
this(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, Collections.emptyList(),
invokerAdvisors, filters);
invokerAdvisors, filters, Collections.emptyList());
}
/**
@@ -85,14 +87,16 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
* @param endpointPathMappers the endpoint path mappers
* @param additionalPathsMappers the
* @param invokerAdvisors invoker advisors to apply
* @param filters filters to apply
* @param endpointFilters endpoint filters to apply
* @param operationFilters operation filters to apply
* @since 3.4.0
*/
public WebEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
EndpointMediaTypes endpointMediaTypes, List<PathMapper> endpointPathMappers,
List<AdditionalPathsMapper> additionalPathsMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
super(applicationContext, parameterValueMapper, invokerAdvisors, filters);
Collection<EndpointFilter<ExposableWebEndpoint>> endpointFilters,
Collection<OperationFilter<WebOperation>> 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<ExposableWebEndpoi
}
@Override
protected ExposableWebEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault,
protected ExposableWebEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess,
Collection<WebOperation> 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);
}

View File

@@ -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<RequestMethod> READ_ONLY_ACCESS_REQUEST_METHODS = EnumSet.of(RequestMethod.GET,
RequestMethod.HEAD);
private final EndpointMapping endpointMapping;
private final CorsConfiguration corsConfiguration;
private final Map<Object, ExposableControllerEndpoint> 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<ExposableControllerEndpoint> 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<ExposableControllerEndpoint> 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<RequestMethod> methods = mapping.getMethodsCondition().getMethods();
Set<RequestMethod> 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<PathPattern> patterns = mapping.getPatternsCondition().getPatterns();

View File

@@ -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<RequestMethod> READ_ONLY_ACCESS_REQUEST_METHODS = EnumSet.of(RequestMethod.GET,
RequestMethod.HEAD);
private final EndpointMapping endpointMapping;
private final CorsConfiguration corsConfiguration;
private final Map<Object, ExposableControllerEndpoint> 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<ExposableControllerEndpoint> 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<ExposableControllerEndpoint> 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<RequestMethod> methods = mapping.getMethodsCondition().getMethods();
Set<RequestMethod> 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<PathPattern> patterns = mapping.getPathPatternsCondition().getPatterns();

View File

@@ -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<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableEndpoint<Operation>>> 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<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableEndpoint<Operation>>> filters) {
super(applicationContext, parameterValueMapper, invokerAdvisors, filters);
super(applicationContext, parameterValueMapper, invokerAdvisors, filters, Collections.emptyList());
}
}

View File

@@ -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<SpecializedExposableEndpoint> 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<EndpointId, SpecializedExposableEndpoint> endpoints = mapEndpoints(discoverer.getEndpoints());
assertThat(endpoints).containsOnlyKeys(EndpointId.of("test"));
});
}
@Test
void getEndpointsShouldApplyOperationFilters() {
load(SpecializedEndpointsConfiguration.class, (context) -> {
OperationFilter<SpecializedOperation> operationFilter = (operation, endpointId,
defaultAccess) -> operation.getType() == OperationType.READ;
SpecializedEndpointDiscoverer discoverer = new SpecializedEndpointDiscoverer(context,
Collections.emptyList(), List.of(operationFilter));
Map<EndpointId, SpecializedExposableEndpoint> 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<EndpointId, TestExposableEndpoint> endpoints = mapEndpoints(discoverer.getEndpoints());
@@ -536,10 +553,17 @@ class EndpointDiscovererTests {
TestEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<TestExposableEndpoint>> filters) {
super(applicationContext, parameterValueMapper, invokerAdvisors, filters);
super(applicationContext, parameterValueMapper, invokerAdvisors, filters, Collections.emptyList());
}
@Override
protected TestExposableEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess,
Collection<TestOperation> operations) {
return new TestExposableEndpoint(this, endpointBean, id, defaultAccess, operations);
}
@Override
@SuppressWarnings("removal")
protected TestExposableEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault,
Collection<TestOperation> operations) {
return new TestExposableEndpoint(this, endpointBean, id, enabledByDefault, operations);
@@ -563,15 +587,24 @@ class EndpointDiscovererTests {
extends EndpointDiscoverer<SpecializedExposableEndpoint, SpecializedOperation> {
SpecializedEndpointDiscoverer(ApplicationContext applicationContext) {
this(applicationContext, Collections.emptyList());
this(applicationContext, Collections.emptyList(), Collections.emptyList());
}
SpecializedEndpointDiscoverer(ApplicationContext applicationContext,
Collection<EndpointFilter<SpecializedExposableEndpoint>> filters) {
super(applicationContext, new ConversionServiceParameterValueMapper(), Collections.emptyList(), filters);
Collection<EndpointFilter<SpecializedExposableEndpoint>> filters,
Collection<OperationFilter<SpecializedOperation>> operationFilters) {
super(applicationContext, new ConversionServiceParameterValueMapper(), Collections.emptyList(), filters,
operationFilters);
}
@Override
protected SpecializedExposableEndpoint createEndpoint(Object endpointBean, EndpointId id, Access defaultAccess,
Collection<SpecializedOperation> operations) {
return new SpecializedExposableEndpoint(this, endpointBean, id, defaultAccess, operations);
}
@Override
@SuppressWarnings("removal")
protected SpecializedExposableEndpoint createEndpoint(Object endpointBean, EndpointId id,
boolean enabledByDefault, Collection<SpecializedOperation> operations) {
return new SpecializedExposableEndpoint(this, endpointBean, id, enabledByDefault, operations);
@@ -593,6 +626,12 @@ class EndpointDiscovererTests {
static class TestExposableEndpoint extends AbstractDiscoveredEndpoint<TestOperation> {
TestExposableEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id,
Access defaultAccess, Collection<? extends TestOperation> operations) {
super(discoverer, endpointBean, id, defaultAccess, operations);
}
@SuppressWarnings("removal")
TestExposableEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id,
boolean enabledByDefault, Collection<? extends TestOperation> operations) {
super(discoverer, endpointBean, id, enabledByDefault, operations);
@@ -602,6 +641,13 @@ class EndpointDiscovererTests {
static class SpecializedExposableEndpoint extends AbstractDiscoveredEndpoint<SpecializedOperation> {
@SuppressWarnings("removal")
SpecializedExposableEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id,
Access defaultAccess, Collection<? extends SpecializedOperation> operations) {
super(discoverer, endpointBean, id, defaultAccess, operations);
}
@SuppressWarnings("removal")
SpecializedExposableEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id,
boolean enabledByDefault, Collection<? extends SpecializedOperation> operations) {
super(discoverer, endpointBean, id, enabledByDefault, operations);

View File

@@ -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()));

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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<AssertableReactiveWebApplicationContext> withWebTestClient(
Consumer<WebTestClient> 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";
}
}
}

View File

@@ -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;

View File

@@ -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<AssertableWebApplicationContext> withWebTestClient(Consumer<WebTestClient> 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";
}
}
}

View File

@@ -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;

View File

@@ -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<Resource> 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);

View File

@@ -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]

View File

@@ -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.<id>.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.<id>.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

View File

@@ -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

View File

@@ -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<String> 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

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -50,7 +50,7 @@ public class MyExtensionConfiguration {
List<EndpointFilter<ExposableWebEndpoint>> 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<ExposableWebEndpoint> endpoints = discoverer.getEndpoints();
return new MyExtensionWebMvcEndpointHandlerMapping(endpoints, endpointMediaTypes, corsConfiguration);
}