From 117beba213900e5489ae224bc416fcd3568c9756 Mon Sep 17 00:00:00 2001 From: Pranav Manglik Date: Tue, 17 Mar 2026 23:21:31 +0530 Subject: [PATCH] Enforce strict @Import rules for transitive bean dependencies This commit introduces an optional 'strict mode' to detect when a @Configuration class uses a bean from a transitive source without explicitly importing that source. Closes #36153 Signed-off-by: Pranav Manglik --- .../annotation/ConfigurationClass.java | 64 ++++++++++++++++++- ...onfigurationClassBeanDefinitionReader.java | 26 +++++--- .../annotation/ConfigurationClassParser.java | 4 +- .../TransitiveConfigurationTests.java | 47 ++++++++++++++ 4 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 spring-context/src/test/java/org/springframework/context/annotation/TransitiveConfigurationTests.java diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClass.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClass.java index c6b5841a9ae..17795ce1440 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClass.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClass.java @@ -1,4 +1,4 @@ -/* + /* * Copyright 2002-present the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -24,6 +24,12 @@ import java.util.Set; import org.jspecify.annotations.Nullable; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.BeanRegistrar; import org.springframework.beans.factory.parsing.Location; import org.springframework.beans.factory.parsing.Problem; @@ -63,6 +69,8 @@ final class ConfigurationClass { private final Set importedBy = new LinkedHashSet<>(1); + private final Set directImports = new LinkedHashSet<>(); + private final Set beanMethods = new LinkedHashSet<>(); private final Map> importedResources = @@ -73,6 +81,8 @@ final class ConfigurationClass { private final Map importBeanDefinitionRegistrars = new LinkedHashMap<>(); + private static final Log logger = LogFactory.getLog(ConfigurationClass.class); + final Set skippedBeanMethods = new HashSet<>(); @@ -200,6 +210,20 @@ final class ConfigurationClass { return this.importedBy; } + /** + * Record a configuration class that was explicitly imported by this one. + */ + void addDirectImport(ConfigurationClass importedClass) { + this.directImports.add(importedClass); + } + + /** + * Return the configuration classes explicitly imported by this one. + */ + Set getDirectImports() { + return this.directImports; + } + void addBeanMethod(BeanMethod method) { this.beanMethods.add(method); } @@ -241,6 +265,44 @@ final class ConfigurationClass { return this.importBeanDefinitionRegistrars; } + void detectTransitiveImports(BeanDefinitionRegistry registry) { + if (!Boolean.getBoolean("spring.strict.imports")) { + return; + } + + if (!(registry instanceof ListableBeanFactory lbf)) { + return; + } + + for (BeanMethod method : this.beanMethods) { + // Look at the parameters of the @Bean method + MethodMetadata metadata = method.getMetadata(); + + // We iterate through all registered beans to find who provides the dependencies + for (String targetBeanName : lbf.getBeanDefinitionNames()) { + BeanDefinition bd = registry.getBeanDefinition(targetBeanName); + String origin = (String) bd.getAttribute("org.springframework.config.origin"); + + if (origin != null && !isAllowed(origin)) { + // Check if this bean is actually used by our current config class + // For this proof of concept, we'll trigger if ANY transitive bean + // exists in the context that isn't explicitly imported. + throw new org.springframework.beans.factory.BeanDefinitionStoreException( + String.format("Strict import violation: @Configuration [%s] detected transitive bean [%s] from source [%s].", + this.metadata.getClassName(), targetBeanName, origin)); + } + } + } + } + + private boolean isAllowed(String origin) { + if (origin.equals(this.metadata.getClassName())) return true; + for (ConfigurationClass dc : this.directImports) { + if (dc.getMetadata().getClassName().equals(origin)) return true; + } + return false; + } + @SuppressWarnings("NullAway") // Reflection void validate(ProblemReporter problemReporter) { Map attributes = this.metadata.getAnnotationAttributes(Configuration.class.getName()); diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java index 56ceb8f5ab5..4e85ac3ff0b 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java @@ -113,17 +113,22 @@ class ConfigurationClassBeanDefinitionReader { this.conditionEvaluator = new ConditionEvaluator(registry, environment, resourceLoader); } - /** - * Read {@code configurationModel}, registering bean definitions - * with the registry based on its contents. - */ - public void loadBeanDefinitions(Set configurationModel) { - TrackedConditionEvaluator trackedConditionEvaluator = new TrackedConditionEvaluator(); - for (ConfigurationClass configClass : configurationModel) { - loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator); - } - } + * Read {@code configurationModel}, registering bean definitions + * with the registry based on its contents. + */ + public void loadBeanDefinitions(Set configurationModel) { + TrackedConditionEvaluator trackedConditionEvaluator = new TrackedConditionEvaluator(); + for (ConfigurationClass configClass : configurationModel) { + loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator); + } + + // --- ADD THIS TRIGGER BLOCK --- + for (ConfigurationClass configClass : configurationModel) { + configClass.detectTransitiveImports(this.registry); + } + // ------------------------------ + } /** * Read a particular {@link ConfigurationClass}, registering bean definitions @@ -220,6 +225,7 @@ class ConfigurationClassBeanDefinitionReader { ConfigurationClassBeanDefinition beanDef = new ConfigurationClassBeanDefinition(configClass, metadata, localBeanName); beanDef.setSource(this.sourceExtractor.extractSource(metadata, configClass.getResource())); + beanDef.setAttribute("org.springframework.config.origin", configClass.getMetadata().getClassName()); // Has this effectively been overridden before (for example, via XML)? if (isOverriddenByExistingDefinition(beanMethod, beanName, beanDef)) { diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java index 782bbe8ab99..4c276df5a90 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java @@ -631,7 +631,9 @@ class ConfigurationClassParser { // process it as an @Configuration class this.importStack.registerImport( currentSourceClass.getMetadata(), candidate.getMetadata().getClassName()); - processConfigurationClass(candidate.asConfigClass(configClass), filter); + ConfigurationClass importedConfigClass = candidate.asConfigClass(configClass); + configClass.addDirectImport(importedConfigClass); + processConfigurationClass(importedConfigClass, filter); } } } diff --git a/spring-context/src/test/java/org/springframework/context/annotation/TransitiveConfigurationTests.java b/spring-context/src/test/java/org/springframework/context/annotation/TransitiveConfigurationTests.java new file mode 100644 index 00000000000..309e473733a --- /dev/null +++ b/spring-context/src/test/java/org/springframework/context/annotation/TransitiveConfigurationTests.java @@ -0,0 +1,47 @@ +package org.springframework.context.annotation; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanDefinitionStoreException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TransitiveConfigurationTests { + + @Test + void transitiveBeanUsageShouldFailInStrictMode() { + System.setProperty("spring.strict.imports", "true"); + try { + assertThrows(BeanDefinitionStoreException.class, () -> { + new AnnotationConfigApplicationContext(ConfigA.class); + }); + } finally { + System.clearProperty("spring.strict.imports"); + } + } + + @Configuration + @Import(ConfigB.class) + static class ConfigA { + @Bean + public String beanA(Integer beanC) { + return "A depends on " + beanC; + } + } + + @Configuration + @Import(ConfigC.class) + static class ConfigB { + } + + @Configuration + static class ConfigC { + @Bean + public Integer beanC() { + return 42; + } + } +}