001/*
002 * Copyright 2012-2018 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.boot.actuate.context.properties;
018
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025
026import com.fasterxml.jackson.annotation.JsonInclude.Include;
027import com.fasterxml.jackson.core.JsonGenerator;
028import com.fasterxml.jackson.databind.BeanDescription;
029import com.fasterxml.jackson.databind.MapperFeature;
030import com.fasterxml.jackson.databind.ObjectMapper;
031import com.fasterxml.jackson.databind.SerializationConfig;
032import com.fasterxml.jackson.databind.SerializationFeature;
033import com.fasterxml.jackson.databind.SerializerProvider;
034import com.fasterxml.jackson.databind.introspect.Annotated;
035import com.fasterxml.jackson.databind.introspect.AnnotatedMethod;
036import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
037import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
038import com.fasterxml.jackson.databind.ser.BeanSerializerFactory;
039import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
040import com.fasterxml.jackson.databind.ser.PropertyWriter;
041import com.fasterxml.jackson.databind.ser.SerializerFactory;
042import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
043import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
044import org.apache.commons.logging.Log;
045import org.apache.commons.logging.LogFactory;
046
047import org.springframework.beans.BeansException;
048import org.springframework.boot.actuate.endpoint.Sanitizer;
049import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
050import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
051import org.springframework.boot.context.properties.ConfigurationBeanFactoryMetadata;
052import org.springframework.boot.context.properties.ConfigurationProperties;
053import org.springframework.context.ApplicationContext;
054import org.springframework.context.ApplicationContextAware;
055import org.springframework.util.ClassUtils;
056import org.springframework.util.StringUtils;
057
058/**
059 * {@link Endpoint} to expose application properties from {@link ConfigurationProperties}
060 * annotated beans.
061 *
062 * <p>
063 * To protect sensitive information from being exposed, certain property values are masked
064 * if their names end with a set of configurable values (default "password" and "secret").
065 * Configure property names by using {@code endpoints.configprops.keys_to_sanitize} in
066 * your Spring Boot application configuration.
067 *
068 * @author Christian Dupuis
069 * @author Dave Syer
070 * @author Stephane Nicoll
071 * @since 2.0.0
072 */
073@Endpoint(id = "configprops")
074public class ConfigurationPropertiesReportEndpoint implements ApplicationContextAware {
075
076        private static final String CONFIGURATION_PROPERTIES_FILTER_ID = "configurationPropertiesFilter";
077
078        private final Sanitizer sanitizer = new Sanitizer();
079
080        private ApplicationContext context;
081
082        private ObjectMapper objectMapper;
083
084        @Override
085        public void setApplicationContext(ApplicationContext context) throws BeansException {
086                this.context = context;
087        }
088
089        public void setKeysToSanitize(String... keysToSanitize) {
090                this.sanitizer.setKeysToSanitize(keysToSanitize);
091        }
092
093        @ReadOperation
094        public ApplicationConfigurationProperties configurationProperties() {
095                return extract(this.context);
096        }
097
098        private ApplicationConfigurationProperties extract(ApplicationContext context) {
099                Map<String, ContextConfigurationProperties> contextProperties = new HashMap<>();
100                ApplicationContext target = context;
101                while (target != null) {
102                        contextProperties.put(target.getId(),
103                                        describeConfigurationProperties(target, getObjectMapper()));
104                        target = target.getParent();
105                }
106                return new ApplicationConfigurationProperties(contextProperties);
107        }
108
109        private ContextConfigurationProperties describeConfigurationProperties(
110                        ApplicationContext context, ObjectMapper mapper) {
111                ConfigurationBeanFactoryMetadata beanFactoryMetadata = getBeanFactoryMetadata(
112                                context);
113                Map<String, Object> beans = getConfigurationPropertiesBeans(context,
114                                beanFactoryMetadata);
115                Map<String, ConfigurationPropertiesBeanDescriptor> beanDescriptors = new HashMap<>();
116                beans.forEach((beanName, bean) -> {
117                        String prefix = extractPrefix(context, beanFactoryMetadata, beanName);
118                        beanDescriptors.put(beanName, new ConfigurationPropertiesBeanDescriptor(
119                                        prefix, sanitize(prefix, safeSerialize(mapper, bean, prefix))));
120                });
121                return new ContextConfigurationProperties(beanDescriptors,
122                                (context.getParent() != null) ? context.getParent().getId() : null);
123        }
124
125        private ConfigurationBeanFactoryMetadata getBeanFactoryMetadata(
126                        ApplicationContext context) {
127                Map<String, ConfigurationBeanFactoryMetadata> beans = context
128                                .getBeansOfType(ConfigurationBeanFactoryMetadata.class);
129                if (beans.size() == 1) {
130                        return beans.values().iterator().next();
131                }
132                return null;
133        }
134
135        private Map<String, Object> getConfigurationPropertiesBeans(
136                        ApplicationContext context,
137                        ConfigurationBeanFactoryMetadata beanFactoryMetadata) {
138                Map<String, Object> beans = new HashMap<>();
139                beans.putAll(context.getBeansWithAnnotation(ConfigurationProperties.class));
140                if (beanFactoryMetadata != null) {
141                        beans.putAll(beanFactoryMetadata
142                                        .getBeansWithFactoryAnnotation(ConfigurationProperties.class));
143                }
144                return beans;
145        }
146
147        /**
148         * Cautiously serialize the bean to a map (returning a map with an error message
149         * instead of throwing an exception if there is a problem).
150         * @param mapper the object mapper
151         * @param bean the source bean
152         * @param prefix the prefix
153         * @return the serialized instance
154         */
155        @SuppressWarnings("unchecked")
156        private Map<String, Object> safeSerialize(ObjectMapper mapper, Object bean,
157                        String prefix) {
158                try {
159                        return new HashMap<>(mapper.convertValue(bean, Map.class));
160                }
161                catch (Exception ex) {
162                        return new HashMap<>(Collections.singletonMap("error",
163                                        "Cannot serialize '" + prefix + "'"));
164                }
165        }
166
167        /**
168         * Configure Jackson's {@link ObjectMapper} to be used to serialize the
169         * {@link ConfigurationProperties} objects into a {@link Map} structure.
170         * @param mapper the object mapper
171         */
172        protected void configureObjectMapper(ObjectMapper mapper) {
173                mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
174                mapper.configure(MapperFeature.USE_STD_BEAN_NAMING, true);
175                mapper.setSerializationInclusion(Include.NON_NULL);
176                applyConfigurationPropertiesFilter(mapper);
177                applySerializationModifier(mapper);
178        }
179
180        private ObjectMapper getObjectMapper() {
181                if (this.objectMapper == null) {
182                        this.objectMapper = new ObjectMapper();
183                        configureObjectMapper(this.objectMapper);
184                }
185                return this.objectMapper;
186        }
187
188        /**
189         * Ensure only bindable and non-cyclic bean properties are reported.
190         * @param mapper the object mapper
191         */
192        private void applySerializationModifier(ObjectMapper mapper) {
193                SerializerFactory factory = BeanSerializerFactory.instance
194                                .withSerializerModifier(new GenericSerializerModifier());
195                mapper.setSerializerFactory(factory);
196        }
197
198        private void applyConfigurationPropertiesFilter(ObjectMapper mapper) {
199                mapper.setAnnotationIntrospector(
200                                new ConfigurationPropertiesAnnotationIntrospector());
201                mapper.setFilterProvider(new SimpleFilterProvider()
202                                .setDefaultFilter(new ConfigurationPropertiesPropertyFilter()));
203        }
204
205        /**
206         * Extract configuration prefix from {@link ConfigurationProperties} annotation.
207         * @param context the application context
208         * @param beanFactoryMetaData the bean factory meta-data
209         * @param beanName the bean name
210         * @return the prefix
211         */
212        private String extractPrefix(ApplicationContext context,
213                        ConfigurationBeanFactoryMetadata beanFactoryMetaData, String beanName) {
214                ConfigurationProperties annotation = context.findAnnotationOnBean(beanName,
215                                ConfigurationProperties.class);
216                if (beanFactoryMetaData != null) {
217                        ConfigurationProperties override = beanFactoryMetaData
218                                        .findFactoryAnnotation(beanName, ConfigurationProperties.class);
219                        if (override != null) {
220                                // The @Bean-level @ConfigurationProperties overrides the one at type
221                                // level when binding. Arguably we should render them both, but this one
222                                // might be the most relevant for a starting point.
223                                annotation = override;
224                        }
225                }
226                return annotation.prefix();
227        }
228
229        /**
230         * Sanitize all unwanted configuration properties to avoid leaking of sensitive
231         * information.
232         * @param prefix the property prefix
233         * @param map the source map
234         * @return the sanitized map
235         */
236        @SuppressWarnings("unchecked")
237        private Map<String, Object> sanitize(String prefix, Map<String, Object> map) {
238                map.forEach((key, value) -> {
239                        String qualifiedKey = (prefix.isEmpty() ? prefix : prefix + ".") + key;
240                        if (value instanceof Map) {
241                                map.put(key, sanitize(qualifiedKey, (Map<String, Object>) value));
242                        }
243                        else if (value instanceof List) {
244                                map.put(key, sanitize(qualifiedKey, (List<Object>) value));
245                        }
246                        else {
247                                value = this.sanitizer.sanitize(key, value);
248                                value = this.sanitizer.sanitize(qualifiedKey, value);
249                                map.put(key, value);
250                        }
251                });
252                return map;
253        }
254
255        @SuppressWarnings("unchecked")
256        private List<Object> sanitize(String prefix, List<Object> list) {
257                List<Object> sanitized = new ArrayList<>();
258                for (Object item : list) {
259                        if (item instanceof Map) {
260                                sanitized.add(sanitize(prefix, (Map<String, Object>) item));
261                        }
262                        else if (item instanceof List) {
263                                sanitized.add(sanitize(prefix, (List<Object>) item));
264                        }
265                        else {
266                                sanitized.add(this.sanitizer.sanitize(prefix, item));
267                        }
268                }
269                return sanitized;
270        }
271
272        /**
273         * Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean
274         * properties.
275         */
276        @SuppressWarnings("serial")
277        private static class ConfigurationPropertiesAnnotationIntrospector
278                        extends JacksonAnnotationIntrospector {
279
280                @Override
281                public Object findFilterId(Annotated a) {
282                        Object id = super.findFilterId(a);
283                        if (id == null) {
284                                id = CONFIGURATION_PROPERTIES_FILTER_ID;
285                        }
286                        return id;
287                }
288
289        }
290
291        /**
292         * {@link SimpleBeanPropertyFilter} for serialization of
293         * {@link ConfigurationProperties} beans. The filter hides:
294         *
295         * <ul>
296         * <li>Properties that have a name starting with '$$'.
297         * <li>Properties that are self-referential.
298         * <li>Properties that throw an exception when retrieving their value.
299         * </ul>
300         */
301        private static class ConfigurationPropertiesPropertyFilter
302                        extends SimpleBeanPropertyFilter {
303
304                private static final Log logger = LogFactory
305                                .getLog(ConfigurationPropertiesPropertyFilter.class);
306
307                @Override
308                protected boolean include(BeanPropertyWriter writer) {
309                        return include(writer.getFullName().getSimpleName());
310                }
311
312                @Override
313                protected boolean include(PropertyWriter writer) {
314                        return include(writer.getFullName().getSimpleName());
315                }
316
317                private boolean include(String name) {
318                        return !name.startsWith("$$");
319                }
320
321                @Override
322                public void serializeAsField(Object pojo, JsonGenerator jgen,
323                                SerializerProvider provider, PropertyWriter writer) throws Exception {
324                        if (writer instanceof BeanPropertyWriter) {
325                                try {
326                                        if (pojo == ((BeanPropertyWriter) writer).get(pojo)) {
327                                                if (logger.isDebugEnabled()) {
328                                                        logger.debug("Skipping '" + writer.getFullName() + "' on '"
329                                                                        + pojo.getClass().getName()
330                                                                        + "' as it is self-referential");
331                                                }
332                                                return;
333                                        }
334                                }
335                                catch (Exception ex) {
336                                        if (logger.isDebugEnabled()) {
337                                                logger.debug("Skipping '" + writer.getFullName() + "' on '"
338                                                                + pojo.getClass().getName() + "' as an exception "
339                                                                + "was thrown when retrieving its value", ex);
340                                        }
341                                        return;
342                                }
343                        }
344                        super.serializeAsField(pojo, jgen, provider, writer);
345                }
346
347        }
348
349        /**
350         * {@link BeanSerializerModifier} to return only relevant configuration properties.
351         */
352        protected static class GenericSerializerModifier extends BeanSerializerModifier {
353
354                @Override
355                public List<BeanPropertyWriter> changeProperties(SerializationConfig config,
356                                BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) {
357                        List<BeanPropertyWriter> result = new ArrayList<>();
358                        for (BeanPropertyWriter writer : beanProperties) {
359                                boolean readable = isReadable(beanDesc, writer);
360                                if (readable) {
361                                        result.add(writer);
362                                }
363                        }
364                        return result;
365                }
366
367                private boolean isReadable(BeanDescription beanDesc, BeanPropertyWriter writer) {
368                        Class<?> parentType = beanDesc.getType().getRawClass();
369                        Class<?> type = writer.getType().getRawClass();
370                        AnnotatedMethod setter = findSetter(beanDesc, writer);
371                        // If there's a setter, we assume it's OK to report on the value,
372                        // similarly, if there's no setter but the package names match, we assume
373                        // that its a nested class used solely for binding to config props, so it
374                        // should be kosher. Lists and Maps are also auto-detected by default since
375                        // that's what the metadata generator does. This filter is not used if there
376                        // is JSON metadata for the property, so it's mainly for user-defined beans.
377                        return (setter != null)
378                                        || ClassUtils.getPackageName(parentType)
379                                                        .equals(ClassUtils.getPackageName(type))
380                                        || Map.class.isAssignableFrom(type)
381                                        || Collection.class.isAssignableFrom(type);
382                }
383
384                private AnnotatedMethod findSetter(BeanDescription beanDesc,
385                                BeanPropertyWriter writer) {
386                        String name = "set" + determineAccessorSuffix(writer.getName());
387                        Class<?> type = writer.getType().getRawClass();
388                        AnnotatedMethod setter = beanDesc.findMethod(name, new Class<?>[] { type });
389                        // The enabled property of endpoints returns a boolean primitive but is set
390                        // using a Boolean class
391                        if (setter == null && type.equals(Boolean.TYPE)) {
392                                setter = beanDesc.findMethod(name, new Class<?>[] { Boolean.class });
393                        }
394                        return setter;
395                }
396
397                /**
398                 * Determine the accessor suffix of the specified {@code propertyName}, see
399                 * section 8.8 "Capitalization of inferred names" of the JavaBean specs for more
400                 * details.
401                 * @param propertyName the property name to turn into an accessor suffix
402                 * @return the accessor suffix for {@code propertyName}
403                 */
404                private String determineAccessorSuffix(String propertyName) {
405                        if (propertyName.length() > 1
406                                        && Character.isUpperCase(propertyName.charAt(1))) {
407                                return propertyName;
408                        }
409                        return StringUtils.capitalize(propertyName);
410                }
411
412        }
413
414        /**
415         * A description of an application's {@link ConfigurationProperties} beans. Primarily
416         * intended for serialization to JSON.
417         */
418        public static final class ApplicationConfigurationProperties {
419
420                private final Map<String, ContextConfigurationProperties> contexts;
421
422                private ApplicationConfigurationProperties(
423                                Map<String, ContextConfigurationProperties> contexts) {
424                        this.contexts = contexts;
425                }
426
427                public Map<String, ContextConfigurationProperties> getContexts() {
428                        return this.contexts;
429                }
430
431        }
432
433        /**
434         * A description of an application context's {@link ConfigurationProperties} beans.
435         * Primarily intended for serialization to JSON.
436         */
437        public static final class ContextConfigurationProperties {
438
439                private final Map<String, ConfigurationPropertiesBeanDescriptor> beans;
440
441                private final String parentId;
442
443                private ContextConfigurationProperties(
444                                Map<String, ConfigurationPropertiesBeanDescriptor> beans,
445                                String parentId) {
446                        this.beans = beans;
447                        this.parentId = parentId;
448                }
449
450                public Map<String, ConfigurationPropertiesBeanDescriptor> getBeans() {
451                        return this.beans;
452                }
453
454                public String getParentId() {
455                        return this.parentId;
456                }
457
458        }
459
460        /**
461         * A description of a {@link ConfigurationProperties} bean. Primarily intended for
462         * serialization to JSON.
463         */
464        public static final class ConfigurationPropertiesBeanDescriptor {
465
466                private final String prefix;
467
468                private final Map<String, Object> properties;
469
470                private ConfigurationPropertiesBeanDescriptor(String prefix,
471                                Map<String, Object> properties) {
472                        this.prefix = prefix;
473                        this.properties = properties;
474                }
475
476                public String getPrefix() {
477                        return this.prefix;
478                }
479
480                public Map<String, Object> getProperties() {
481                        return this.properties;
482                }
483
484        }
485
486}