/*
* Copyright 2012-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.endpoint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
import com.fasterxml.jackson.databind.ser.BeanSerializerFactory;
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
import com.fasterxml.jackson.databind.ser.PropertyWriter;
import com.fasterxml.jackson.databind.ser.SerializerFactory;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import org.springframework.beans.BeansException;
import org.springframework.boot.context.properties.ConfigurationBeanFactoryMetaData;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
/**
* {@link Endpoint} to expose application properties from {@link ConfigurationProperties}
* annotated beans.
*
* <p>
* To protect sensitive information from being exposed, certain property values are masked
* if their names end with a set of configurable values (default "password" and "secret").
* Configure property names by using {@code endpoints.configprops.keys_to_sanitize} in
* your Spring Boot application configuration.
*
* @author Christian Dupuis
* @author Dave Syer
*/
@ConfigurationProperties(prefix = "endpoints.configprops")
public class ConfigurationPropertiesReportEndpoint
extends AbstractEndpoint<Map<String, Object>> implements ApplicationContextAware {
private static final String CGLIB_FILTER_ID = "cglibFilter";
private final Sanitizer sanitizer = new Sanitizer();
private ApplicationContext context;
public ConfigurationPropertiesReportEndpoint() {
super("configprops");
}
@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {
this.context = context;
}
public void setKeysToSanitize(String... keysToSanitize) {
this.sanitizer.setKeysToSanitize(keysToSanitize);
}
@Override
public Map<String, Object> invoke() {
return extract(this.context);
}
/**
* Extract beans annotated {@link ConfigurationProperties} and serialize into
* {@link Map}.
* @param context the application context
* @return the beans
*/
protected Map<String, Object> extract(ApplicationContext context) {
// Serialize beans into map structure and sanitize values
ObjectMapper mapper = new ObjectMapper();
configureObjectMapper(mapper);
return extract(context, mapper);
}
private Map<String, Object> extract(ApplicationContext context, ObjectMapper mapper) {
Map<String, Object> result = new HashMap<>();
ConfigurationBeanFactoryMetaData beanFactoryMetaData = getBeanFactoryMetaData(
context);
Map<String, Object> beans = getConfigurationPropertiesBeans(context,
beanFactoryMetaData);
for (Map.Entry<String, Object> entry : beans.entrySet()) {
String beanName = entry.getKey();
Object bean = entry.getValue();
Map<String, Object> root = new HashMap<>();
String prefix = extractPrefix(context, beanFactoryMetaData, beanName, bean);
root.put("prefix", prefix);
root.put("properties", sanitize(prefix, safeSerialize(mapper, bean, prefix)));
result.put(beanName, root);
}
if (context.getParent() != null) {
result.put("parent", extract(context.getParent(), mapper));
}
return result;
}
private ConfigurationBeanFactoryMetaData getBeanFactoryMetaData(
ApplicationContext context) {
Map<String, ConfigurationBeanFactoryMetaData> beans = context
.getBeansOfType(ConfigurationBeanFactoryMetaData.class);
if (beans.size() == 1) {
return beans.values().iterator().next();
}
return null;
}
private Map<String, Object> getConfigurationPropertiesBeans(
ApplicationContext context,
ConfigurationBeanFactoryMetaData beanFactoryMetaData) {
Map<String, Object> beans = new HashMap<>();
beans.putAll(context.getBeansWithAnnotation(ConfigurationProperties.class));
if (beanFactoryMetaData != null) {
beans.putAll(beanFactoryMetaData
.getBeansWithFactoryAnnotation(ConfigurationProperties.class));
}
return beans;
}
/**
* Cautiously serialize the bean to a map (returning a map with an error message
* instead of throwing an exception if there is a problem).
* @param mapper the object mapper
* @param bean the source bean
* @param prefix the prefix
* @return the serialized instance
*/
private Map<String, Object> safeSerialize(ObjectMapper mapper, Object bean,
String prefix) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> result = new HashMap<>(
mapper.convertValue(bean, Map.class));
return result;
}
catch (Exception ex) {
return new HashMap<>(Collections.<String, Object>singletonMap("error",
"Cannot serialize '" + prefix + "'"));
}
}
/**
* Configure Jackson's {@link ObjectMapper} to be used to serialize the
* {@link ConfigurationProperties} objects into a {@link Map} structure.
* @param mapper the object mapper
*/
protected void configureObjectMapper(ObjectMapper mapper) {
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
mapper.setSerializationInclusion(Include.NON_NULL);
applyCglibFilters(mapper);
applySerializationModifier(mapper);
}
/**
* Ensure only bindable and non-cyclic bean properties are reported.
* @param mapper the object mapper
*/
private void applySerializationModifier(ObjectMapper mapper) {
SerializerFactory factory = BeanSerializerFactory.instance
.withSerializerModifier(new GenericSerializerModifier());
mapper.setSerializerFactory(factory);
}
/**
* Configure PropertyFilter to make sure Jackson doesn't process CGLIB generated bean
* properties.
* @param mapper the object mapper
*/
private void applyCglibFilters(ObjectMapper mapper) {
mapper.setAnnotationIntrospector(new CglibAnnotationIntrospector());
mapper.setFilterProvider(new SimpleFilterProvider().addFilter(CGLIB_FILTER_ID,
new CglibBeanPropertyFilter()));
}
/**
* Extract configuration prefix from {@link ConfigurationProperties} annotation.
* @param context the application context
* @param beanFactoryMetaData the bean factory meta-data
* @param beanName the bean name
* @param bean the bean
* @return the prefix
*/
private String extractPrefix(ApplicationContext context,
ConfigurationBeanFactoryMetaData beanFactoryMetaData, String beanName,
Object bean) {
ConfigurationProperties annotation = context.findAnnotationOnBean(beanName,
ConfigurationProperties.class);
if (beanFactoryMetaData != null) {
ConfigurationProperties override = beanFactoryMetaData
.findFactoryAnnotation(beanName, ConfigurationProperties.class);
if (override != null) {
// The @Bean-level @ConfigurationProperties overrides the one at type
// level when binding. Arguably we should render them both, but this one
// might be the most relevant for a starting point.
annotation = override;
}
}
return annotation.prefix();
}
/**
* Sanitize all unwanted configuration properties to avoid leaking of sensitive
* information.
* @param prefix the property prefix
* @param map the source map
* @return the sanitized map
*/
@SuppressWarnings("unchecked")
private Map<String, Object> sanitize(String prefix, Map<String, Object> map) {
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = entry.getKey();
String qualifiedKey = (prefix.isEmpty() ? prefix : prefix + ".") + key;
Object value = entry.getValue();
if (value instanceof Map) {
map.put(key, sanitize(qualifiedKey, (Map<String, Object>) value));
}
else if (value instanceof List) {
map.put(key, sanitize(qualifiedKey, (List<Object>) value));
}
else {
value = this.sanitizer.sanitize(key, value);
value = this.sanitizer.sanitize(qualifiedKey, value);
map.put(key, value);
}
}
return map;
}
@SuppressWarnings("unchecked")
private List<Object> sanitize(String prefix, List<Object> list) {
List<Object> sanitized = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map) {
sanitized.add(sanitize(prefix, (Map<String, Object>) item));
}
else if (item instanceof List) {
sanitized.add(sanitize(prefix, (List<Object>) item));
}
else {
sanitized.add(this.sanitizer.sanitize(prefix, item));
}
}
return sanitized;
}
/**
* Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean
* properties.
*/
@SuppressWarnings("serial")
private static class CglibAnnotationIntrospector
extends JacksonAnnotationIntrospector {
@Override
public Object findFilterId(Annotated a) {
Object id = super.findFilterId(a);
if (id == null) {
id = CGLIB_FILTER_ID;
}
return id;
}
}
/**
* {@link SimpleBeanPropertyFilter} to filter out all bean properties whose names
* start with '$$'.
*/
private static class CglibBeanPropertyFilter extends SimpleBeanPropertyFilter {
@Override
protected boolean include(BeanPropertyWriter writer) {
return include(writer.getFullName().getSimpleName());
}
@Override
protected boolean include(PropertyWriter writer) {
return include(writer.getFullName().getSimpleName());
}
private boolean include(String name) {
return !name.startsWith("$$");
}
}
/**
* {@link BeanSerializerModifier} to return only relevant configuration properties.
*/
protected static class GenericSerializerModifier extends BeanSerializerModifier {
@Override
public List<BeanPropertyWriter> changeProperties(SerializationConfig config,
BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) {
List<BeanPropertyWriter> result = new ArrayList<>();
for (BeanPropertyWriter writer : beanProperties) {
boolean readable = isReadable(beanDesc, writer);
if (readable) {
result.add(writer);
}
}
return result;
}
private boolean isReadable(BeanDescription beanDesc, BeanPropertyWriter writer) {
Class<?> parentType = beanDesc.getType().getRawClass();
Class<?> type = writer.getType().getRawClass();
AnnotatedMethod setter = findSetter(beanDesc, writer);
// If there's a setter, we assume it's OK to report on the value,
// similarly, if there's no setter but the package names match, we assume
// that its a nested class used solely for binding to config props, so it
// should be kosher. This filter is not used if there is JSON metadata for
// the property, so it's mainly for user-defined beans.
return (setter != null) || ClassUtils.getPackageName(parentType)
.equals(ClassUtils.getPackageName(type));
}
private AnnotatedMethod findSetter(BeanDescription beanDesc,
BeanPropertyWriter writer) {
String name = "set" + StringUtils.capitalize(writer.getName());
Class<?> type = writer.getType().getRawClass();
AnnotatedMethod setter = beanDesc.findMethod(name, new Class<?>[] { type });
// The enabled property of endpoints returns a boolean primitive but is set
// using a Boolean class
if (setter == null && type.equals(Boolean.TYPE)) {
setter = beanDesc.findMethod(name, new Class<?>[] { Boolean.class });
}
return setter;
}
}
}