diff --git a/README.md b/README.md
index 3e696cf40..6caea0a69 100644
--- a/README.md
+++ b/README.md
@@ -52,9 +52,27 @@ Then publish the jar files to mavenLocal for usage:
### Spring Boot Auto-Configuration
-The plugin automatically excludes 7 Spring Boot security auto-configuration classes that conflict with the Grails Spring Security plugin. No manual `spring.autoconfigure.exclude` entries are needed.
+The plugin automatically excludes Spring Boot's servlet security auto-configuration classes that conflict with the Grails Spring Security plugin. No manual `spring.autoconfigure.exclude` entries are needed.
-To disable this automatic exclusion (e.g. if you want to use Spring Boot's security auto-configuration directly), add the following to `application.yml`:
+**Configuration contract:** while this exclusion is enabled (the default), `grails.plugin.springsecurity.*` is the authoritative configuration source for the application's security. Spring Boot's `spring.security.*` properties are *not* merged into the plugin configuration and are *not* applied by Boot's auto-configuration. Use the plugin's keys, not Spring Boot's, to configure security when this plugin is active.
+
+#### Coexistence with the component-based Spring Security configuration model
+
+Spring Security 5.7 deprecated and Spring Security 6 removed `WebSecurityConfigurerAdapter`, replacing it with a component-based configuration model that registers individual `@Bean` components (see [Spring Security without the WebSecurityConfigurerAdapter](https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter)). This plugin pre-dates that model and provides equivalent functionality through the `grails.plugin.springsecurity.*` configuration namespace, but it now **blends** the most common component-based patterns automatically. You can configure security from either side (or both) and the effective configuration is the union of both sources.
+
+| Spring component-based pattern | Blending behaviour with this plugin active | Disable via |
+|---|---|---|
+| `@Bean SecurityFilterChain` | **Auto-merged** into the plugin's `FilterChainProxy`; user chains are prepended (higher precedence) so their typically more-specific request matchers win against the plugin's catch-all chain. | `grails.plugin.springsecurity.componentBased.autoMergeSecurityFilterChain: false` |
+| `@Bean AuthenticationProvider` | **Auto-merged** into the plugin's `authenticationManager` (`ProviderManager`); user providers are appended so the plugin's primary GORM-backed provider runs first; providers already declared via `providerNames` are not re-added. | `grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false` |
+| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` (or any extra `UserDetailsService`) | For each additional `UserDetailsService` bean, a new `DaoAuthenticationProvider` is created and **appended** to the plugin's `authenticationManager` providers list. The plugin's primary GORM-backed provider runs first; if it does not authenticate the user, each additional provider is tried in turn. (Spring Security 7 made `DaoAuthenticationProvider.userDetailsService` final, so we add new providers instead of mutating the existing one.) | `grails.plugin.springsecurity.componentBased.autoChainUserDetailsServices: false` |
+| `spring.security.user.name` / `spring.security.user.password` / `spring.security.user.roles` | If `spring.security.user.name` is set, an `InMemoryUserDetailsManager` is created from those properties (mimicking what Spring Boot's `UserDetailsServiceAutoConfiguration` would have done), wrapped in a `DaoAuthenticationProvider`, and added to the plugin's authenticationManager. | `grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false` |
+| `@Bean WebSecurityCustomizer` | Still a no-op. The plugin does not use Spring's `WebSecurity` builder. To exclude URLs from security checks, use `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`. | n/a |
+| `@Bean AuthenticationManager` | Conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean by name. Use `@Bean AuthenticationProvider` (auto-merged - see above) or `grails.plugin.springsecurity.providerNames` instead. | n/a |
+| LDAP factory beans (`EmbeddedLdapServerContextSourceFactoryBean`, `LdapBindAuthenticationManagerFactory`, `LdapPasswordComparisonAuthenticationManagerFactory`) | Coexist but are not wired into the plugin's authentication providers. | Use the `grails-spring-security-ldap` plugin and the `grails.plugin.springsecurity.ldap.*` configuration. |
+
+To delegate the entire servlet security stack to Spring Boot's component-based model (and stop using the plugin's `grails.plugin.springsecurity.*` configuration), disable the excluder via the property below.
+
+To disable this automatic exclusion (e.g. if you want to delegate the entire servlet security stack to Spring Boot instead of the plugin), add the following to `application.yml`:
```yml
grails:
@@ -63,17 +81,40 @@ grails:
excludeSpringSecurityAutoConfiguration: false
```
-If you are on an older version of the plugin that does not support automatic exclusion, you can manually exclude the conflicting classes:
+Disabling the exclusion is intentionally a footgun: the plugin can no longer guarantee that its filter chain is the only servlet security stack in the application context, and a startup `WARN` will be logged.
+
+If you are on an older version of the plugin that does not support automatic exclusion, you can manually exclude the conflicting classes.
+
+For Grails 8 / Spring Boot 4 (security auto-configurations live under `org.springframework.boot.security.*`):
+
+```yml
+spring:
+ autoconfigure:
+ exclude:
+ - org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
+ - org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration
+ - org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration
+ - org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration
+ - org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration
+ - org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration
+ - org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration
+ - org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration
+ - org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration
+ - org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration
+ - org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration
+```
+
+For Grails 7 / Spring Boot 3 (security auto-configurations live under `org.springframework.boot.autoconfigure.security.*`):
```yml
spring:
autoconfigure:
exclude:
- - org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration
- - org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration
- org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
- - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration
+ - org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration
+ - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
+ - org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration
- org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration
- org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration
- - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
+ - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration
```
diff --git a/docs/src/docs/upgrading/upgrading7x.adoc b/docs/src/docs/upgrading/upgrading7x.adoc
index 013653747..ba6ea94a3 100644
--- a/docs/src/docs/upgrading/upgrading7x.adoc
+++ b/docs/src/docs/upgrading/upgrading7x.adoc
@@ -105,7 +105,7 @@ The underlying Spring Security framework has been upgraded from 5.8.x to 6.x.
Key changes that may affect your application:
* **Jakarta EE namespace**: Spring Security 6 uses `jakarta.servlet` instead of `javax.servlet`. See the https://docs.spring.io/spring-security/reference/migration/index.html[Spring Security Migration Guide] for details.
-* **Security filter chain configuration**: The `WebSecurityConfigurerAdapter` has been removed in Spring Security 6. If you have custom security configurations extending this class, you will need to migrate to the component-based approach. The Grails Spring Security plugin handles the core filter chain configuration internally, but any custom Spring Security beans may need updating.
+* **Security filter chain configuration**: The `WebSecurityConfigurerAdapter` has been removed in Spring Security 6. If you have custom security configurations extending this class, you will need to migrate to the component-based approach described in https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter[Spring Security without the WebSecurityConfigurerAdapter]. The Grails Spring Security plugin handles the core filter chain configuration internally through the `grails.plugin.springsecurity.*` namespace, so for most apps no migration is needed. However, user-defined `@Bean SecurityFilterChain`, `@Bean WebSecurityCustomizer`, `@Bean AuthenticationManager`, and `@Bean UserDetailsManager` beans are not automatically wired into the plugin's filter chain, authentication providers, or user lookup. See the "Coexistence with the component-based Spring Security configuration model" section in the Installation chapter for the full coexistence matrix and the equivalent plugin configuration for each pattern.
* **Default security behaviors**: Some default behaviors have changed in Spring Security 6 (e.g., CSRF protection, session management). Review the https://docs.spring.io/spring-security/reference/migration-7/index.html[Spring Security reference documentation] for the complete list of changes.
=== Automated Migration
diff --git a/gradle.properties b/gradle.properties
index 7d6331fd6..8b35e731b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -30,6 +30,7 @@ gbenchVersion=0.4.3-groovy-2.4
gradleCryptoChecksumVersion=1.4.0
grailsRedisVersion=5.0.1
guavaVersion=33.5.0-jre
+jlineReaderVersion=3.30.9
mailVersion=5.0.2
nimbusVersion=10.5
pac4jVersion=6.2.2
diff --git a/plugin-core/docs/src/docs/introduction/installation.adoc b/plugin-core/docs/src/docs/introduction/installation.adoc
index 08cfdf21f..ab14d12f0 100644
--- a/plugin-core/docs/src/docs/introduction/installation.adoc
+++ b/plugin-core/docs/src/docs/introduction/installation.adoc
@@ -74,9 +74,50 @@ dependencies {
=== Spring Boot Auto-Configuration
-The plugin automatically excludes Spring Boot security auto-configuration classes (such as `SecurityAutoConfiguration`, `SecurityFilterAutoConfiguration`, and `UserDetailsServiceAutoConfiguration`) that conflict with the plugin's own security setup. This means you do not need to manually add `spring.autoconfigure.exclude` entries to your `application.yml`.
+The plugin automatically excludes Spring Boot's servlet security auto-configuration classes (such as `SecurityAutoConfiguration`, `SecurityFilterAutoConfiguration`, `UserDetailsServiceAutoConfiguration`, the OAuth2 client/resource-server/authorization-server starters, and the SAML2 relying-party starter) that conflict with the plugin's own security setup. This means you do not need to manually add `spring.autoconfigure.exclude` entries to your `application.yml`.
-If you need to disable this automatic exclusion - for example, to use Spring Boot's security auto-configuration directly alongside the plugin - set the following property in `application.yml`:
+IMPORTANT: While the plugin is active, `grails.plugin.springsecurity.*` is the authoritative configuration namespace for security. Spring Boot's `spring.security.*` properties are not merged into the plugin configuration. Use the plugin's keys to configure security; do not mix them with Boot's `spring.security.*` keys.
+
+==== Coexistence with the component-based Spring Security configuration model
+
+Spring Security 5.7 deprecated and Spring Security 6 removed `WebSecurityConfigurerAdapter`, replacing it with a component-based configuration model that registers individual `@Bean` components (see https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter[Spring Security without the WebSecurityConfigurerAdapter]). This plugin pre-dates that model and provides equivalent functionality through the `grails.plugin.springsecurity.*` configuration namespace, but it now *blends* the most common component-based patterns automatically. You can configure security from either side (or both) and the effective configuration is the union of both sources.
+
+[cols="1,1,1",options="header"]
+|===
+| Spring component-based pattern | Blending behaviour with this plugin active | Disable via
+
+| `@Bean SecurityFilterChain`
+| *Auto-merged* into the plugin's `FilterChainProxy`; user chains are prepended (higher precedence) so their typically more-specific request matchers win against the plugin's catch-all chain.
+| `grails.plugin.springsecurity.componentBased.autoMergeSecurityFilterChain: false`
+
+| `@Bean AuthenticationProvider`
+| *Auto-merged* into the plugin's `authenticationManager` (`ProviderManager`); user providers are appended so the plugin's primary GORM-backed provider runs first; providers already declared via `providerNames` are not re-added.
+| `grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false`
+
+| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` (or any extra `UserDetailsService`)
+| For each additional `UserDetailsService` bean, a new `DaoAuthenticationProvider` is created and *appended* to the plugin's `authenticationManager` providers list. The plugin's primary GORM-backed provider runs first; if it does not authenticate the user, each additional provider is tried in turn. (Spring Security 7 made `DaoAuthenticationProvider.userDetailsService` final, so we add new providers instead of mutating the existing one.)
+| `grails.plugin.springsecurity.componentBased.autoChainUserDetailsServices: false`
+
+| `spring.security.user.name` / `spring.security.user.password` / `spring.security.user.roles`
+| If `spring.security.user.name` is set, an `InMemoryUserDetailsManager` is created from those properties (mimicking what Spring Boot's `UserDetailsServiceAutoConfiguration` would have done), wrapped in a `DaoAuthenticationProvider`, and added to the plugin's authenticationManager.
+| `grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false`
+
+| `@Bean WebSecurityCustomizer`
+| Still a no-op. The plugin does not use Spring's `WebSecurity` builder. To exclude URLs from security checks, use `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`.
+| n/a
+
+| `@Bean AuthenticationManager`
+| Conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean by name. Use `@Bean AuthenticationProvider` (auto-merged - see above) or `grails.plugin.springsecurity.providerNames` instead.
+| n/a
+
+| LDAP factory beans (`EmbeddedLdapServerContextSourceFactoryBean`, `LdapBindAuthenticationManagerFactory`, `LdapPasswordComparisonAuthenticationManagerFactory`)
+| Coexist but are not wired into the plugin's authentication providers.
+| Use the `grails-spring-security-ldap` plugin and the `grails.plugin.springsecurity.ldap.*` configuration.
+|===
+
+To delegate the entire servlet security stack to Spring Boot's component-based model (and stop using the plugin's `grails.plugin.springsecurity.*` configuration), disable the excluder via the property below.
+
+If you need to disable this automatic exclusion - for example, to delegate the entire servlet security stack to Spring Boot instead of the plugin - set the following property in `application.yml`:
[source,yaml]
----
@@ -86,6 +127,8 @@ grails:
excludeSpringSecurityAutoConfiguration: false
----
+WARNING: Disabling the exclusion is intentionally a footgun. The plugin can no longer guarantee that its filter chain is the only servlet security stack in the application context, and a startup `WARN` will be logged.
+
=== Verifying Installation
To verify that the plugin has been successfully installed, you can run a simple test:
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp
index 1c0c4f0e3..a359d1b63 100644
--- a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp
+++ b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp
@@ -47,7 +47,7 @@
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp
index ff434df59..1151cfa54 100644
--- a/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp
+++ b/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp
@@ -46,7 +46,7 @@
diff --git a/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy b/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy
new file mode 100644
index 000000000..b77633e1d
--- /dev/null
+++ b/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.plugin.springsecurity
+
+import spock.lang.Specification
+
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.web.SecurityFilterChain
+
+import grails.testing.mixin.integration.Integration
+
+@Integration
+class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification {
+
+ @Autowired
+ ConfigurableApplicationContext applicationContext
+
+ void "SecurityAutoConfigurationExcluder class is on the classpath"() {
+ expect:
+ Class.forName(
+ 'grails.plugin.springsecurity.SecurityAutoConfigurationExcluder'
+ )
+ }
+
+ void "no Spring Boot SecurityFilterChain bean is registered alongside the plugin"() {
+ given: 'the application context bean factory'
+ ConfigurableListableBeanFactory beanFactory = applicationContext.beanFactory
+
+ and: 'all SecurityFilterChain beans visible to the application context'
+ def filterChainBeans = applicationContext.getBeanNamesForType(SecurityFilterChain)
+
+ expect: 'none come from Spring Boot security auto-configurations'
+ filterChainBeans.every { name ->
+ !beanFactory.getBeanDefinition(name).beanClassName?.startsWith('org.springframework.boot.security.')
+ }
+
+ and: 'none of the excluded auto-configuration class names are registered as beans'
+ SecurityAutoConfigurationExcluder.excludedAutoConfigurations.each { className ->
+ assert !beanFactory.containsBeanDefinition(className) :
+ "Spring Boot auto-configuration ${className} should be excluded by SecurityAutoConfigurationExcluder"
+ }
+ }
+
+ void "no Spring Boot in-memory UserDetailsService is registered alongside the plugin"() {
+ given: 'the application context bean factory'
+ ConfigurableListableBeanFactory beanFactory = applicationContext.beanFactory
+
+ and: 'all UserDetailsService beans visible to the application context'
+ def udsBeans = applicationContext.getBeanNamesForType(UserDetailsService)
+
+ expect: 'at least one (the plugin one) exists'
+ udsBeans.length >= 1
+
+ and: 'none come from Spring Boot security auto-configurations'
+ udsBeans.every { name ->
+ !beanFactory.getBeanDefinition(name).beanClassName?.startsWith('org.springframework.boot.security.')
+ }
+
+ and: "Boot's in-memory UserDetailsService is not present"
+ !udsBeans.any { it == 'inMemoryUserDetailsManager' }
+ }
+}
diff --git a/plugin-core/plugin/build.gradle b/plugin-core/plugin/build.gradle
index 1776c90b7..38f427f1e 100644
--- a/plugin-core/plugin/build.gradle
+++ b/plugin-core/plugin/build.gradle
@@ -75,6 +75,7 @@ dependencies {
compileOnly 'jakarta.servlet:jakarta.servlet-api' // Provided
compileOnly 'jline:jline' // for shell commands
+ compileOnly "org.jline:jline-reader:$jlineReaderVersion" // Required for Groovy static type checking (referenced via Groovy 5.x groovysh by the grails-plugin gradle compiler)
compileOnly 'org.apache.grails:grails-core' // Provided as this is a Grails Plugin
compileOnly 'org.apache.groovy:groovy' // Compile-time annotations
compileOnly 'org.slf4j:slf4j-nop' // Prevents warnings about missing slf4j implementation during compilation
diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy
index 889e2aa8c..bc810630c 100644
--- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy
@@ -23,6 +23,28 @@ import org.springframework.security.access.hierarchicalroles.RoleHierarchy
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl
import org.springframework.security.core.GrantedAuthority
+/**
+ * A mutable {@link RoleHierarchy} that allows the hierarchy definition to be replaced
+ * at runtime, working around the immutability of Spring Security's
+ * {@link RoleHierarchyImpl}.
+ *
+ *
Spring Security 6 made {@link RoleHierarchyImpl} effectively immutable by
+ * removing public mutators in favour of {@code RoleHierarchyImpl.fromHierarchy(...)}.
+ * This class wraps a delegate that is rebuilt every time the hierarchy string is
+ * assigned, so callers (such as the plugin's {@code roleHierarchy} bean and any
+ * application code that reads {@code grails.plugin.springsecurity.roleHierarchy}
+ * at runtime) keep their original setter-based API.
+ *
+ *
The {@code hierarchy} property uses the standard Spring Security syntax,
+ * with one assignment per line, e.g.:
Setting the property to {@code null} or an empty string clears the hierarchy.
+ */
@CompileStatic
class MutableRoleHierarchy implements RoleHierarchy {
diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy
new file mode 100644
index 000000000..6cc0d4449
--- /dev/null
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.plugin.springsecurity
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter
+import org.springframework.boot.autoconfigure.AutoConfigurationMetadata
+import org.springframework.context.EnvironmentAware
+import org.springframework.core.env.Environment
+
+/**
+ * Automatically excludes Spring Boot security auto-configuration classes that
+ * conflict with the Grails Spring Security plugin.
+ *
+ *
Why this filter exists
+ *
+ *
The Grails Spring Security plugin owns the servlet security stack of a
+ * Grails application: it builds its own {@code FilterChainProxy}
+ * ({@code springSecurityFilterChain}), {@code UserDetailsService}
+ * ({@code GormUserDetailsService}), and request-mapping/access-decision
+ * infrastructure from the {@code grails.plugin.springsecurity.*} configuration
+ * namespace.
+ *
+ *
Spring Boot ships its own auto-configurations that try to do the same job
+ * from the {@code spring.security.*} configuration namespace. When both are
+ * active, Spring Boot can register an additional {@code SecurityFilterChain},
+ * an in-memory {@code UserDetailsService}, OAuth2 client/authorization-server
+ * filter chains, etc., resulting in two parallel servlet security stacks with
+ * no defined precedence between them.
+ *
+ *
Configuration contract: while this filter is enabled
+ * (the default), {@code grails.plugin.springsecurity.*} is the authoritative
+ * configuration source for the application's security. Boot's
+ * {@code spring.security.*} properties are not merged into the plugin
+ * configuration and are not applied by Boot's auto-configuration.
+ * Use the plugin's keys, not Spring Boot's, to configure security when this
+ * plugin is active.
+ *
+ *
Coexistence with the component-based Spring Security configuration model
+ *
+ *
Spring Security 5.7 deprecated and Spring Security 6 removed
+ * {@code WebSecurityConfigurerAdapter}, replacing it with a component-based
+ * configuration model that registers individual {@code @Bean} components
+ * (see
+ * Spring Security without the WebSecurityConfigurerAdapter).
+ *
+ *
This plugin pre-dates that model and provides equivalent functionality
+ * through the {@code grails.plugin.springsecurity.*} configuration namespace,
+ * but it now blends the most common component-based patterns
+ * automatically via {@link grails.plugin.springsecurity.componentbased.ComponentBasedConfigBlender}.
+ * This means you can configure security from either side (or both) and the
+ * effective configuration is the union of both sources.
+ *
+ *
The following table summarises how each component-based pattern is
+ * blended with the plugin's configuration:
+ *
+ *
+ *
{@code @Bean SecurityFilterChain} - user-defined
+ * {@code SecurityFilterChain} beans are auto-merged
+ * into the plugin's {@code FilterChainProxy}. User chains are prepended
+ * (higher precedence) so their typically more-specific request matchers
+ * win against the plugin's catch-all chain. Disable via
+ * {@code grails.plugin.springsecurity.componentBased.autoMergeSecurityFilterChain: false}.
+ *
{@code @Bean WebSecurityCustomizer} - still a no-op.
+ * The plugin does not use Spring's {@code WebSecurity} builder. To
+ * exclude URLs from security checks, use
+ * {@code grails.plugin.springsecurity.ipRestrictions} or
+ * {@code grails.plugin.springsecurity.staticRules} with
+ * {@code permitAll} access.
+ *
{@code @Bean AuthenticationManager} - the plugin
+ * registers an {@code authenticationManager} bean (a
+ * {@code ProviderManager}). A user-defined bean with the same name
+ * will fail with a duplicate-bean error. To plug in custom
+ * authentication providers, define {@code @Bean AuthenticationProvider}
+ * beans (auto-merged - see the next entry) or add their bean names to
+ * {@code grails.plugin.springsecurity.providerNames}.
+ *
{@code @Bean AuthenticationProvider} - user-defined
+ * {@code AuthenticationProvider} beans are auto-merged
+ * into the plugin's {@code authenticationManager}. User providers are
+ * appended so the plugin's primary GORM-backed provider runs first;
+ * providers already in the manager (e.g. those declared via
+ * {@code providerNames}) are not re-added. Disable via
+ * {@code grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false}.
+ *
{@code @Bean UserDetailsManager} /
+ * {@code InMemoryUserDetailsManager} /
+ * {@code JdbcUserDetailsManager} - for each additional
+ * {@code UserDetailsService} bean, a new
+ * {@code DaoAuthenticationProvider} is created and appended to the
+ * plugin's {@code authenticationManager} providers list. The plugin's
+ * primary GORM-backed provider runs first; if it does not authenticate
+ * the user, each additional provider is tried in turn. (We cannot
+ * rewire the existing {@code daoAuthenticationProvider} because Spring
+ * Security 7 made its {@code userDetailsService} a final
+ * constructor-only field.) Disable via
+ * {@code grails.plugin.springsecurity.componentBased.autoChainUserDetailsServices: false}.
+ *
{@code spring.security.user.name} /
+ * {@code spring.security.user.password} /
+ * {@code spring.security.user.roles} - if
+ * {@code spring.security.user.name} is set, an
+ * {@code InMemoryUserDetailsManager} is created from those properties
+ * (mimicking what Spring Boot's
+ * {@code UserDetailsServiceAutoConfiguration} would have done), wrapped
+ * in a {@code DaoAuthenticationProvider} and added to the plugin's
+ * authenticationManager. Disable via
+ * {@code grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false}.
+ *
LDAP factory beans
+ * ({@code EmbeddedLdapServerContextSourceFactoryBean},
+ * {@code LdapBindAuthenticationManagerFactory},
+ * {@code LdapPasswordComparisonAuthenticationManagerFactory})
+ * - the {@code grails-spring-security-ldap} plugin provides equivalent
+ * configuration through {@code grails.plugin.springsecurity.ldap.*}.
+ * User-defined LDAP factory beans coexist but are not wired into the
+ * plugin's authentication providers.
+ *
+ *
+ *
To delegate the entire servlet security stack to Spring Boot's
+ * component-based model (and stop using the plugin's
+ * {@code grails.plugin.springsecurity.*} configuration), disable this filter
+ * - see the "Opt-out" section below.
+ *
+ *
What this filter excludes
+ *
+ *
In Spring Boot 4 the security auto-configurations were moved out of
+ * {@code spring-boot-autoconfigure} into dedicated {@code spring-boot-security*}
+ * modules and re-packaged under {@code org.springframework.boot.security.*}.
+ * Adding any of those starters/modules to a Grails application would otherwise
+ * re-introduce the conflicting servlet auto-configurations. This filter excludes
+ * them during Spring Boot's auto-configuration discovery phase, so users do not
+ * need to maintain a manual {@code spring.autoconfigure.exclude} list.
+ *
+ *
Opt-out
+ *
+ *
To disable this filter and allow Spring Boot's security auto-configurations
+ * to run (for example, to delegate the entire servlet security stack to Spring
+ * Boot instead of the plugin), set the following property in
+ * {@code application.yml}:
Disabling this filter is intentionally a footgun: the plugin can no longer
+ * guarantee that its filter chain is the only servlet security stack in the
+ * application context, and a startup {@code WARN} is logged when it is turned
+ * off.
+ *
+ *
Registered via {@code META-INF/spring.factories} as an
+ * {@link AutoConfigurationImportFilter}. This runs before auto-configuration
+ * bytecode is loaded, so there is no performance overhead from excluded classes.
+ *
+ * @since 7.0.2
+ * @see AutoConfigurationImportFilter
+ */
+@CompileStatic
+@Slf4j
+class SecurityAutoConfigurationExcluder implements AutoConfigurationImportFilter, EnvironmentAware {
+
+ static final String ENABLED_PROPERTY = 'grails.plugin.springsecurity.excludeSpringSecurityAutoConfiguration'
+
+ private boolean enabled = true
+
+ @Override
+ void setEnvironment(Environment environment) {
+ this.enabled = environment.getProperty(ENABLED_PROPERTY, Boolean, true)
+ if (!this.enabled) {
+ log.warn(
+ 'Spring Boot security auto-configuration exclusion is DISABLED via {}=false. ' +
+ 'Spring Boot may now register a parallel servlet security stack alongside the ' +
+ 'Grails Spring Security plugin (additional SecurityFilterChain, UserDetailsService, ' +
+ 'or OAuth2/SAML2 filter chains). The plugin can no longer guarantee that its ' +
+ 'filter chain is the only servlet security stack in the application context.',
+ ENABLED_PROPERTY)
+ }
+ }
+
+ /**
+ * Spring Boot 4 servlet security auto-configuration classes that conflict
+ * with the Grails Spring Security plugin's servlet security stack. These
+ * are excluded unconditionally when the plugin is on the classpath.
+ *
+ *
Verified against the
+ * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}
+ * files in the following Spring Boot 4 modules:
+ * {@code spring-boot-security}, {@code spring-boot-security-oauth2-client},
+ * {@code spring-boot-security-oauth2-resource-server},
+ * {@code spring-boot-security-saml2}, and
+ * {@code spring-boot-security-oauth2-authorization-server}.
+ *
+ *
Reactive variants (e.g. {@code ReactiveWebSecurityAutoConfiguration},
+ * {@code ReactiveOAuth2ResourceServerAutoConfiguration}) are intentionally
+ * NOT excluded here. They are guarded by
+ * {@code @ConditionalOnWebApplication(REACTIVE)} and do not activate in a
+ * standard servlet-based Grails application; mixed servlet/reactive
+ * security is outside this plugin's threat model.
+ *
+ *
+ *
{@code SecurityAutoConfiguration} - enables {@code SecurityProperties}
+ * and contributes {@code AuthenticationEventPublisher}/{@code SecurityDataConfiguration}
+ * that conflict with the plugin's wiring
+ *
{@code UserDetailsServiceAutoConfiguration} - creates an in-memory
+ * {@code UserDetailsService} from {@code spring.security.user.*}
+ * properties that conflicts with the plugin's
+ * {@code GormUserDetailsService}
+ *
{@code SecurityFilterAutoConfiguration} - registers a
+ * {@code DelegatingFilterProxyRegistrationBean} that duplicates the
+ * plugin's {@code springSecurityFilterChainRegistrationBean}
+ *
{@code ServletWebSecurityAutoConfiguration} - new in Spring Boot 4;
+ * contributes the servlet {@code SecurityFilterChain} wiring that
+ * conflicts with the plugin's filter chain
+ *
{@code ManagementWebSecurityAutoConfiguration} - Actuator security
+ * that conflicts when Actuator is on the classpath
+ *
{@code OAuth2ClientAutoConfiguration} - registers the
+ * {@code ClientRegistrationRepository} and authorized-client services
+ * that the {@code spring-security-oauth2} plugin owns
+ *
{@code OAuth2ClientWebSecurityAutoConfiguration} - registers the
+ * OAuth2 client servlet {@code SecurityFilterChain} that duplicates
+ * the plugin's OAuth2 client filter chain
+ *
{@code OAuth2ResourceServerAutoConfiguration} (servlet) - configures
+ * a JWT/Opaque-token-based {@code SecurityFilterChain} that conflicts
+ * with the plugin's REST/token authentication wiring
+ *
{@code Saml2RelyingPartyAutoConfiguration} - registers a SAML2
+ * relying-party {@code SecurityFilterChain} that conflicts with the
+ * plugin's SAML wiring
+ *
{@code OAuth2AuthorizationServerAutoConfiguration} (servlet) -
+ * registers a high-precedence authorization-server
+ * {@code SecurityFilterChain} plus a default
+ * {@code anyRequest().authenticated()} chain that would override the
+ * plugin's request-mapping rules
+ *
{@code OAuth2AuthorizationServerJwtAutoConfiguration} (servlet) -
+ * authorization-server JWT companion that pulls in additional
+ * authorization-server beans
+ *
+ */
+ private static final Set EXCLUDED_AUTO_CONFIGURATIONS = [
+ 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration',
+ 'org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration',
+ ].toSet().asImmutable()
+
+ @Override
+ boolean[] match(String[] autoConfigurationClasses, AutoConfigurationMetadata autoConfigurationMetadata) {
+ autoConfigurationClasses.collect {
+ !enabled || !(it in EXCLUDED_AUTO_CONFIGURATIONS)
+ } as boolean[]
+ }
+
+ /**
+ * Returns the set of auto-configuration class names that this filter excludes.
+ * Exposed for testing and diagnostic purposes.
+ *
+ * @return unmodifiable set of excluded class names
+ */
+ static Set getExcludedAutoConfigurations() {
+ EXCLUDED_AUTO_CONFIGURATIONS
+ }
+}
diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy
index 69e9755fb..9791d97ae 100644
--- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy
@@ -36,7 +36,7 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean
@CompileStatic
class SpringSecurityBeanFactoryPostProcessor implements BeanFactoryPostProcessor {
- protected static final String AUTOCONFIG_NAME = 'org.springframework.boot.autoconfigure.security.SecurityFilterAutoConfiguration'
+ protected static final String AUTOCONFIG_NAME = 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration'
protected static final String SECURITY_PROPERTIES_NAME = 'securityProperties'
void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy
index 2715b1216..4206bdf3b 100644
--- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy
@@ -25,6 +25,7 @@ import grails.plugin.springsecurity.access.vote.ClosureVoter
import grails.plugin.springsecurity.authentication.GrailsAnonymousAuthenticationProvider
import grails.plugin.springsecurity.authentication.NullAuthenticationEventPublisher
import grails.plugin.springsecurity.cache.SpringUserCacheFactoryBean
+import grails.plugin.springsecurity.componentbased.ComponentBasedConfigBlender
import grails.plugin.springsecurity.userdetails.DefaultPostAuthenticationChecks
import grails.plugin.springsecurity.userdetails.DefaultPreAuthenticationChecks
import grails.plugin.springsecurity.userdetails.GormUserDetailsService
@@ -80,6 +81,7 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent
import org.springframework.security.core.context.SecurityContextHolder as SCH
import org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper
+import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.cache.NullUserCache
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
@@ -696,6 +698,8 @@ to default to 'Annotation'; setting value to 'Annotation'
applicationContext.authenticationManager.providers = createBeanList(providerNames)
log.trace 'AuthenticationProviders: {}', applicationContext.authenticationManager.providers
+ applyComponentBasedConfigBlending conf, applicationContext, securityFilterChains
+
// build handlers list here to give dependent plugins a chance to register some
def logoutHandlerNames = (conf.logout.handlerNames ?: SpringSecurityUtils.logoutHandlerNames) +
(conf.logout.additionalHandlerNames ?: [])
@@ -769,6 +773,57 @@ to default to 'Annotation'; setting value to 'Annotation'
private createBeanList(names) { names.collect { name -> applicationContext.getBean(name) } }
+ private void applyComponentBasedConfigBlending(conf, applicationContext, securityFilterChains) {
+ def cb = conf.componentBased
+ boolean mergeFilterChains = cb?.containsKey('autoMergeSecurityFilterChain') ? cb.autoMergeSecurityFilterChain : true
+ boolean mergeProviders = cb?.containsKey('autoMergeAuthenticationProviders') ? cb.autoMergeAuthenticationProviders : true
+ boolean chainUds = cb?.containsKey('autoChainUserDetailsServices') ? cb.autoChainUserDetailsServices : true
+ boolean bridgeUserProps = cb?.containsKey('bridgeSpringSecurityUserProperties') ? cb.bridgeSpringSecurityUserProperties : true
+
+ if (mergeFilterChains) {
+ ComponentBasedConfigBlender.mergeUserSecurityFilterChains applicationContext, securityFilterChains
+ }
+
+ if (mergeProviders) {
+ ComponentBasedConfigBlender.mergeUserAuthenticationProviders applicationContext, applicationContext.authenticationManager
+ }
+
+ if (chainUds || bridgeUserProps) {
+ def primary = applicationContext.userDetailsService
+ List additional = []
+
+ if (bridgeUserProps) {
+ def env = applicationContext.environment
+ String userName = env.getProperty('spring.security.user.name', String)
+ String userPassword = env.getProperty('spring.security.user.password', String)
+ List userRoles = env.getProperty('spring.security.user.roles', List)
+ def bridged = ComponentBasedConfigBlender.bridgeSpringSecurityUserProperties(userName, userPassword, userRoles)
+ if (bridged != null) {
+ additional << (UserDetailsService) bridged
+ }
+ }
+
+ if (chainUds) {
+ def others = applicationContext.getBeansOfType(UserDetailsService).values().findAll { it !== primary }
+ additional.addAll(others)
+ }
+
+ if (additional) {
+ def passwordEncoder = applicationContext.containsBean('passwordEncoder') ? applicationContext.passwordEncoder : null
+ List additionalProviders = additional.collect { UserDetailsService uds ->
+ def provider = new DaoAuthenticationProvider(uds)
+ if (passwordEncoder != null) {
+ provider.passwordEncoder = passwordEncoder
+ }
+ provider
+ }
+ applicationContext.authenticationManager.providers.addAll additionalProviders
+ log.info 'Added {} DaoAuthenticationProvider(s) for additional UserDetailsService sources to authenticationManager (the plugin GORM-backed provider remains primary)',
+ additionalProviders.size()
+ }
+ }
+ }
+
private configureLogout = { conf ->
securityContextLogoutHandler(classFor('securityContextLogoutHandler', SecurityContextLogoutHandler)) {
diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy
new file mode 100644
index 000000000..0e9f545c8
--- /dev/null
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.plugin.springsecurity.componentbased
+
+import groovy.transform.CompileStatic
+
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.core.userdetails.UsernameNotFoundException
+
+/**
+ * A {@link UserDetailsService} that delegates to a fixed ordered list of
+ * delegate services. The first delegate that successfully resolves the
+ * username wins; if every delegate throws
+ * {@link UsernameNotFoundException}, this service rethrows that exception.
+ *
+ *
Used by {@link ComponentBasedConfigBlender#chainUserDetailsServices} to
+ * blend the Grails plugin's primary {@code userDetailsService} (the GORM-backed
+ * {@code GormUserDetailsService}) with user-defined
+ * {@link org.springframework.security.provisioning.InMemoryUserDetailsManager}
+ * or {@link org.springframework.security.provisioning.JdbcUserDetailsManager}
+ * beans defined per
+ *
+ * Spring Security without the WebSecurityConfigurerAdapter.
+ *
+ * @since 8.0.0
+ */
+@CompileStatic
+class ChainedUserDetailsService implements UserDetailsService {
+
+ final List delegates
+
+ ChainedUserDetailsService(List delegates) {
+ if (!delegates) {
+ throw new IllegalArgumentException('delegates must not be empty')
+ }
+ this.delegates = delegates.asImmutable()
+ }
+
+ @Override
+ UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ UsernameNotFoundException lastException = null
+ for (UserDetailsService delegate in delegates) {
+ try {
+ UserDetails details = delegate.loadUserByUsername(username)
+ if (details != null) {
+ return details
+ }
+ }
+ catch (UsernameNotFoundException ex) {
+ lastException = ex
+ }
+ }
+ throw lastException ?: new UsernameNotFoundException("User '${username}' not found in any chained UserDetailsService")
+ }
+}
diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy
new file mode 100644
index 000000000..13c780936
--- /dev/null
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.plugin.springsecurity.componentbased
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import org.springframework.context.ApplicationContext
+import org.springframework.security.authentication.AuthenticationProvider
+import org.springframework.security.authentication.ProviderManager
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+
+/**
+ * Blends user-defined Spring Security configuration components (the patterns
+ * recommended by
+ *
+ * Spring Security without the WebSecurityConfigurerAdapter) into the
+ * Grails Spring Security plugin's runtime structures.
+ *
+ *
The Grails plugin pre-dates the component-based model and owns the servlet
+ * security stack via its own {@code FilterChainProxy}, {@code ProviderManager}
+ * and {@code GormUserDetailsService}. This blender lets users keep using the
+ * blog post's idioms ({@code @Bean SecurityFilterChain},
+ * {@code @Bean AuthenticationProvider}, {@code spring.security.user.*}) and
+ * have them coexist with the plugin's {@code grails.plugin.springsecurity.*}
+ * configuration instead of being silently ignored.
+ *
+ *
Each merge method is idempotent and safe to invoke multiple times.
+ *
+ *
Each merge is enabled by default and can be disabled individually via
+ * configuration:
+ *
+ * @since 8.0.0
+ */
+@CompileStatic
+@Slf4j
+class ComponentBasedConfigBlender {
+
+ /**
+ * Adds user-defined {@link SecurityFilterChain} beans to the plugin's filter
+ * chain list. User chains are prepended (higher precedence)
+ * because their request matchers are typically more specific than the
+ * plugin's catch-all chain.
+ *
+ *
The plugin's own chains are not registered as named beans (they are
+ * appended directly to {@code securityFilterChains} in
+ * {@code SpringSecurityUtils.buildFilterChains}), so every
+ * {@code SecurityFilterChain} bean visible to the application context is
+ * treated as user-defined.
+ *
+ * @param applicationContext the application context to scan
+ * @param pluginChains the plugin's mutable {@code securityFilterChains} list
+ * (the same list the plugin's {@code FilterChainProxy} references)
+ * @return the number of user chains merged
+ */
+ static int mergeUserSecurityFilterChains(ApplicationContext applicationContext,
+ List pluginChains) {
+ Map beanMap = applicationContext.getBeansOfType(SecurityFilterChain)
+ List userChains = beanMap.values().toList()
+ if (userChains) {
+ pluginChains.addAll(0, userChains)
+ log.info 'Auto-merged {} user-defined SecurityFilterChain beans into the plugin filter chain (prepended for precedence): {}',
+ userChains.size(), beanMap.keySet()
+ }
+ userChains.size()
+ }
+
+ /**
+ * Adds user-defined {@link AuthenticationProvider} beans to the plugin's
+ * {@link ProviderManager}. User providers are appended so
+ * that the plugin's primary providers (typically the GORM-backed DAO
+ * provider) are tried first.
+ *
+ *
Providers already present in the manager (for example, those declared
+ * via {@code grails.plugin.springsecurity.providerNames}) are not
+ * re-added.
+ *
+ * @param applicationContext the application context to scan
+ * @param authenticationManager the plugin's {@code authenticationManager}
+ * bean (a {@code ProviderManager})
+ * @return the number of user providers merged
+ */
+ static int mergeUserAuthenticationProviders(ApplicationContext applicationContext,
+ ProviderManager authenticationManager) {
+ Map beanMap = applicationContext.getBeansOfType(AuthenticationProvider)
+ Set existing = authenticationManager.providers as Set
+ List userProviders = beanMap.values().findAll { !(it in existing) }.toList()
+ if (userProviders) {
+ authenticationManager.providers.addAll(userProviders)
+ Set mergedNames = beanMap.findAll { it.value in userProviders }.keySet()
+ log.info 'Auto-merged {} user-defined AuthenticationProvider beans into the plugin authenticationManager (appended): {}',
+ userProviders.size(), mergedNames
+ }
+ userProviders.size()
+ }
+
+ /**
+ * Wraps the plugin's primary {@code UserDetailsService} bean in a chain that
+ * also queries every other user-defined {@link UserDetailsService} bean in
+ * the application context. The plugin's bean is queried first; user beans
+ * are queried in bean-name order if the plugin's bean throws
+ * {@link org.springframework.security.core.userdetails.UsernameNotFoundException}.
+ *
+ *
This method does not modify the plugin's bean directly. Instead it
+ * returns a {@link UserDetailsService} that callers can substitute in their
+ * authentication providers if they want the chained behaviour. The Grails
+ * plugin core does not currently rewire its providers to use the chained
+ * UDS automatically; users who want this behaviour should declare the
+ * returned service as a bean and reference it via
+ * {@code grails.plugin.springsecurity.dao.userDetailsService} (or the
+ * provider-specific equivalent).
+ *
+ * @param applicationContext the application context to scan
+ * @param primaryUserDetailsService the plugin's primary
+ * {@code userDetailsService} bean (typically
+ * {@code GormUserDetailsService})
+ * @return a chained {@code UserDetailsService}, or the primary unchanged if
+ * no other user beans are present
+ */
+ static UserDetailsService chainUserDetailsServices(ApplicationContext applicationContext,
+ UserDetailsService primaryUserDetailsService) {
+ Map beanMap = applicationContext.getBeansOfType(UserDetailsService)
+ List additional = beanMap.values()
+ .findAll { it !== primaryUserDetailsService }
+ .toList()
+ if (!additional) {
+ return primaryUserDetailsService
+ }
+ List chain = [primaryUserDetailsService] + additional
+ log.info 'Chaining {} additional UserDetailsService beans behind the plugin primary: {}',
+ additional.size(), beanMap.findAll { it.value in additional }.keySet()
+ new ChainedUserDetailsService(chain)
+ }
+
+ /**
+ * If {@code spring.security.user.name} (and optionally
+ * {@code spring.security.user.password} / {@code spring.security.user.roles})
+ * are set, returns an {@link InMemoryUserDetailsManager} containing that
+ * single user, mimicking what Spring Boot's
+ * {@code UserDetailsServiceAutoConfiguration} would have created had it not
+ * been excluded by {@code SecurityAutoConfigurationExcluder}.
+ *
+ *
Defaults follow Spring Boot's defaults:
+ *
+ *
{@code password} - {@code user} (literal)
+ *
{@code roles} - {@code [USER]}
+ *
+ *
+ *
The returned manager is intended to be combined with
+ * {@link #chainUserDetailsServices} so it coexists with the plugin's primary
+ * {@code userDetailsService}. Returns {@code null} if
+ * {@code spring.security.user.name} is not set.
+ *
+ * @param userName the resolved {@code spring.security.user.name} value
+ * @param userPassword the resolved {@code spring.security.user.password}
+ * value (may be {@code null})
+ * @param userRoles the resolved {@code spring.security.user.roles} value
+ * (may be {@code null})
+ * @return the bridged {@code InMemoryUserDetailsManager}, or {@code null} if
+ * the bridge is not applicable
+ */
+ static InMemoryUserDetailsManager bridgeSpringSecurityUserProperties(String userName,
+ String userPassword, List userRoles) {
+ if (!userName) {
+ return null
+ }
+ String password = userPassword ?: 'user'
+ String[] roles = (userRoles ?: ['USER']) as String[]
+ UserDetails user = User.builder()
+ .username(userName)
+ .password('{noop}' + password)
+ .roles(roles)
+ .build()
+ log.info 'Bridging spring.security.user.* properties: created in-memory user "{}" with roles {}',
+ userName, roles.toList()
+ new InMemoryUserDetailsManager([user])
+ }
+}
diff --git a/plugin-core/plugin/src/main/resources/META-INF/spring.factories b/plugin-core/plugin/src/main/resources/META-INF/spring.factories
new file mode 100644
index 000000000..b778c86de
--- /dev/null
+++ b/plugin-core/plugin/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+# Automatically exclude Spring Boot security auto-configurations that conflict
+# with the Grails Spring Security plugin's bean definitions.
+# See: SecurityAutoConfigurationExcluder javadoc for the full list and rationale.
+org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
+ grails.plugin.springsecurity.SecurityAutoConfigurationExcluder
diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy
new file mode 100644
index 000000000..3a7298da4
--- /dev/null
+++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy
@@ -0,0 +1,273 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.plugin.springsecurity
+
+import spock.lang.Specification
+import spock.lang.Subject
+import spock.lang.Unroll
+
+import org.springframework.core.env.Environment
+
+/**
+ * Tests for {@link SecurityAutoConfigurationExcluder}.
+ *
+ * Verifies that Spring Boot 4 servlet security auto-configuration classes that
+ * conflict with the Grails Spring Security plugin are filtered out during the
+ * auto-configuration discovery phase.
+ */
+class SecurityAutoConfigurationExcluderSpec extends Specification {
+
+ @Subject
+ SecurityAutoConfigurationExcluder excluder = new SecurityAutoConfigurationExcluder()
+
+ @Unroll
+ def "match excludes conflicting auto-configuration: #className"() {
+ given:
+ def autoConfigs = [className] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'the conflicting auto-configuration is excluded (false = filtered out)'
+ !results[0]
+
+ where:
+ className << [
+ 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration',
+ 'org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration',
+ ]
+ }
+
+ @Unroll
+ def "match preserves non-security auto-configuration: #className"() {
+ given:
+ def autoConfigs = [className] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'non-security auto-configurations pass through (true = included)'
+ results[0]
+
+ where:
+ className << [
+ 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration',
+ ]
+ }
+
+ @Unroll
+ def "match preserves Spring Boot 3 (pre-move) security auto-configuration class names: #className"() {
+ given: 'these legacy class names are no longer registered as auto-configurations in Spring Boot 4'
+ def autoConfigs = [className] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'the filter is conservative and only excludes the verified Spring Boot 4 names'
+ results[0]
+
+ where:
+ className << [
+ 'org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration',
+ 'org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration',
+ ]
+ }
+
+ @Unroll
+ def "match preserves reactive Spring Boot 4 security auto-configuration: #className"() {
+ given: 'reactive variants are not excluded; they are guarded by ConditionalOnWebApplication(REACTIVE)'
+ def autoConfigs = [className] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'the filter is servlet-only and lets reactive variants pass through'
+ results[0]
+
+ where:
+ className << [
+ 'org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.actuate.web.reactive.ReactiveManagementWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.rsocket.RSocketSecurityAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientWebSecurityAutoConfiguration',
+ 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive.ReactiveOAuth2ResourceServerAutoConfiguration',
+ ]
+ }
+
+ def "match handles mixed array of included and excluded auto-configurations"() {
+ given:
+ def autoConfigs = [
+ 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration',
+ ] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then:
+ results[0] // DataSource - included
+ !results[1] // SecurityAutoConfiguration - excluded
+ results[2] // Jackson - included
+ !results[3] // SecurityFilterAutoConfiguration - excluded
+ results[4] // DispatcherServlet - included
+ }
+
+ def "match handles empty array"() {
+ given:
+ def autoConfigs = [] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then:
+ results.length == 0
+ }
+
+ def "match handles null metadata parameter gracefully"() {
+ given: 'autoConfigurationMetadata is null (not used by this filter)'
+ def autoConfigs = [
+ 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration',
+ ] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'still works correctly'
+ !results[0]
+ }
+
+ def "getExcludedAutoConfigurations returns all 11 known conflicting classes"() {
+ when:
+ def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations
+
+ then:
+ excluded.size() == 11
+ excluded.contains('org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration')
+ excluded.contains('org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration')
+ }
+
+ def "getExcludedAutoConfigurations returns unmodifiable set"() {
+ when:
+ def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations
+ excluded.add('some.new.AutoConfiguration')
+
+ then:
+ thrown(UnsupportedOperationException)
+ }
+
+ def "match allows all auto-configurations when disabled via environment property"() {
+ given:
+ def env = Mock(Environment)
+ env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> false
+ excluder.environment = env
+
+ and:
+ def autoConfigs = [
+ 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration',
+ 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration',
+ 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration',
+ ] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'all auto-configurations pass through when filter is disabled'
+ results[0]
+ results[1]
+ results[2]
+ }
+
+ def "match excludes by default when environment has the property set to true"() {
+ given:
+ def env = Mock(Environment)
+ env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> true
+ excluder.environment = env
+
+ and:
+ def autoConfigs = [
+ 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration',
+ ] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'exclusion is active by default'
+ !results[0]
+ }
+
+ def "match excludes by default when no environment is set"() {
+ given: 'excluder without environment (e.g. unit test usage)'
+ def autoConfigs = [
+ 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration',
+ ] as String[]
+
+ when:
+ def results = excluder.match(autoConfigs, null)
+
+ then: 'exclusion is active by default'
+ !results[0]
+ }
+
+ def "spring.factories registers the filter correctly"() {
+ when: 'enumerating all spring.factories resources on the classpath'
+ def resources = getClass().classLoader.getResources('META-INF/spring.factories')
+ def allContents = resources.collect { it.text }
+
+ then: 'at least one spring.factories exists'
+ !allContents.isEmpty()
+
+ and: 'one of them registers SecurityAutoConfigurationExcluder as an AutoConfigurationImportFilter'
+ allContents.any { content ->
+ content.contains('org.springframework.boot.autoconfigure.AutoConfigurationImportFilter') &&
+ content.contains('grails.plugin.springsecurity.SecurityAutoConfigurationExcluder')
+ }
+ }
+}
diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy
new file mode 100644
index 000000000..7814dc33f
--- /dev/null
+++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 grails.plugin.springsecurity.componentbased
+
+import spock.lang.Specification
+import spock.lang.Subject
+
+import org.springframework.context.ApplicationContext
+import org.springframework.security.authentication.AuthenticationProvider
+import org.springframework.security.authentication.ProviderManager
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.core.userdetails.UsernameNotFoundException
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+
+class ComponentBasedConfigBlenderSpec extends Specification {
+
+ @Subject
+ ComponentBasedConfigBlender blender = new ComponentBasedConfigBlender()
+
+ def "mergeUserSecurityFilterChains prepends user beans (higher precedence)"() {
+ given:
+ SecurityFilterChain pluginChain = Stub(SecurityFilterChain)
+ SecurityFilterChain userChain1 = Stub(SecurityFilterChain)
+ SecurityFilterChain userChain2 = Stub(SecurityFilterChain)
+ ApplicationContext ctx = Mock(ApplicationContext)
+ ctx.getBeansOfType(SecurityFilterChain) >> [user1: userChain1, user2: userChain2]
+
+ List pluginChains = [pluginChain]
+
+ when:
+ int merged = ComponentBasedConfigBlender.mergeUserSecurityFilterChains(ctx, pluginChains)
+
+ then:
+ merged == 2
+ pluginChains.size() == 3
+ pluginChains[0].is(userChain1)
+ pluginChains[1].is(userChain2)
+ pluginChains[2].is(pluginChain)
+ }
+
+ def "mergeUserSecurityFilterChains with no user beans is a no-op"() {
+ given:
+ SecurityFilterChain pluginChain = Stub(SecurityFilterChain)
+ ApplicationContext ctx = Mock(ApplicationContext)
+ ctx.getBeansOfType(SecurityFilterChain) >> [:]
+
+ List pluginChains = [pluginChain]
+
+ when:
+ int merged = ComponentBasedConfigBlender.mergeUserSecurityFilterChains(ctx, pluginChains)
+
+ then:
+ merged == 0
+ pluginChains.size() == 1
+ pluginChains[0].is(pluginChain)
+ }
+
+ def "mergeUserAuthenticationProviders appends user beans (plugin providers run first)"() {
+ given:
+ AuthenticationProvider pluginProvider = Stub(AuthenticationProvider)
+ AuthenticationProvider userProvider1 = Stub(AuthenticationProvider)
+ AuthenticationProvider userProvider2 = Stub(AuthenticationProvider)
+ ApplicationContext ctx = Mock(ApplicationContext)
+ ctx.getBeansOfType(AuthenticationProvider) >> [
+ pluginP: pluginProvider, // simulates plugin's provider also being a named bean
+ userP1: userProvider1,
+ userP2: userProvider2,
+ ]
+
+ ProviderManager mgr = new ProviderManager([pluginProvider])
+
+ when:
+ int merged = ComponentBasedConfigBlender.mergeUserAuthenticationProviders(ctx, mgr)
+
+ then: 'only beans NOT already in the manager are merged'
+ merged == 2
+ mgr.providers.size() == 3
+ mgr.providers[0].is(pluginProvider)
+ mgr.providers[1].is(userProvider1)
+ mgr.providers[2].is(userProvider2)
+ }
+
+ def "mergeUserAuthenticationProviders skips providers already in manager (idempotent)"() {
+ given:
+ AuthenticationProvider pluginProvider = Stub(AuthenticationProvider)
+ AuthenticationProvider userProvider = Stub(AuthenticationProvider)
+ ApplicationContext ctx = Mock(ApplicationContext)
+ ctx.getBeansOfType(AuthenticationProvider) >> [pluginP: pluginProvider, userP: userProvider]
+
+ ProviderManager mgr = new ProviderManager([pluginProvider, userProvider])
+
+ when:
+ int merged = ComponentBasedConfigBlender.mergeUserAuthenticationProviders(ctx, mgr)
+
+ then:
+ merged == 0
+ mgr.providers.size() == 2
+ }
+
+ def "chainUserDetailsServices returns primary unchanged when no other UDS beans"() {
+ given:
+ UserDetailsService primary = Stub(UserDetailsService)
+ ApplicationContext ctx = Mock(ApplicationContext)
+ ctx.getBeansOfType(UserDetailsService) >> [primary: primary]
+
+ when:
+ def result = ComponentBasedConfigBlender.chainUserDetailsServices(ctx, primary)
+
+ then:
+ result.is(primary)
+ }
+
+ def "chainUserDetailsServices wraps primary + additional in a chain"() {
+ given:
+ UserDetailsService primary = Stub(UserDetailsService) {
+ loadUserByUsername('alice') >> { throw new UsernameNotFoundException('not in primary') }
+ loadUserByUsername('bob') >> User.builder().username('bob').password('{noop}b').roles('USER').build()
+ }
+ UserDetailsService secondary = Stub(UserDetailsService) {
+ loadUserByUsername('alice') >> User.builder().username('alice').password('{noop}a').roles('USER').build()
+ loadUserByUsername('bob') >> { throw new UsernameNotFoundException('not in secondary') }
+ }
+ ApplicationContext ctx = Mock(ApplicationContext)
+ ctx.getBeansOfType(UserDetailsService) >> [primary: primary, secondary: secondary]
+
+ when:
+ UserDetailsService chained = ComponentBasedConfigBlender.chainUserDetailsServices(ctx, primary)
+
+ then: 'a chained service is returned'
+ chained instanceof ChainedUserDetailsService
+
+ and: 'primary is queried first (returns its own user without consulting secondary)'
+ chained.loadUserByUsername('bob').username == 'bob'
+
+ and: 'secondary is queried when primary throws UsernameNotFoundException'
+ chained.loadUserByUsername('alice').username == 'alice'
+ }
+
+ def "ChainedUserDetailsService throws UsernameNotFoundException when no delegate finds the user"() {
+ given:
+ UserDetailsService primary = Stub(UserDetailsService) {
+ loadUserByUsername(_) >> { throw new UsernameNotFoundException('not in primary') }
+ }
+ UserDetailsService secondary = Stub(UserDetailsService) {
+ loadUserByUsername(_) >> { throw new UsernameNotFoundException('not in secondary') }
+ }
+ ChainedUserDetailsService chained = new ChainedUserDetailsService([primary, secondary])
+
+ when:
+ chained.loadUserByUsername('carol')
+
+ then:
+ thrown(UsernameNotFoundException)
+ }
+
+ def "bridgeSpringSecurityUserProperties returns null when name not set"() {
+ expect:
+ ComponentBasedConfigBlender.bridgeSpringSecurityUserProperties(null, null, null) == null
+ ComponentBasedConfigBlender.bridgeSpringSecurityUserProperties('', null, null) == null
+ }
+
+ def "bridgeSpringSecurityUserProperties uses Spring Boot defaults when only name is set"() {
+ when:
+ InMemoryUserDetailsManager mgr = ComponentBasedConfigBlender
+ .bridgeSpringSecurityUserProperties('alice', null, null)
+
+ then:
+ mgr != null
+
+ when:
+ UserDetails alice = mgr.loadUserByUsername('alice')
+
+ then:
+ alice.username == 'alice'
+ alice.password == '{noop}user'
+ alice.authorities*.authority.toSet() == ['ROLE_USER'] as Set
+ }
+
+ def "bridgeSpringSecurityUserProperties applies password and roles"() {
+ when:
+ InMemoryUserDetailsManager mgr = ComponentBasedConfigBlender
+ .bridgeSpringSecurityUserProperties('admin', 'secret', ['ADMIN', 'USER'])
+
+ then:
+ UserDetails admin = mgr.loadUserByUsername('admin')
+ admin.username == 'admin'
+ admin.password == '{noop}secret'
+ admin.authorities*.authority.toSet() == ['ROLE_ADMIN', 'ROLE_USER'] as Set
+ }
+}
diff --git a/plugin-oauth2/plugin/build.gradle b/plugin-oauth2/plugin/build.gradle
index afff874e1..6033fd54c 100644
--- a/plugin-oauth2/plugin/build.gradle
+++ b/plugin-oauth2/plugin/build.gradle
@@ -75,6 +75,7 @@ dependencies {
implementation 'jline:jline', {
// comp: ConsoleReader
}
+ compileOnly "org.jline:jline-reader:$jlineReaderVersion" // Required for Groovy static type checking (referenced via Groovy 5.x groovysh by the grails-plugin gradle compiler)
implementation 'org.apache.grails.data:grails-datamapping-core', {
// impl: @Transactional(runtime)
}
diff --git a/spring-security-compat/build.gradle b/spring-security-compat/build.gradle
index e5bdf867e..85ab38f1e 100644
--- a/spring-security-compat/build.gradle
+++ b/spring-security-compat/build.gradle
@@ -26,7 +26,7 @@ group = 'org.apache.grails.security'
ext {
publishArtifactId = 'grails-spring-security-compat'
pomTitle = 'Grails Spring Security Compatibility Module'
- pomDescription = 'Compatibility classes for Grails Spring Security when running against newer Spring Security versions.'
+ pomDescription = 'Compatibility classes that make Grails Spring Security 8 work with newer Spring Security versions, such as 7 and later.'
pomDevelopers = [
matrei: 'Mattias Reichel',
]
diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy
index ba04584d2..b51a7e937 100644
--- a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy
+++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy
@@ -23,6 +23,14 @@ import groovy.transform.CompileStatic
import org.springframework.security.access.AccessDecisionManager
import org.springframework.security.authentication.AuthenticationManager
+/**
+ * Based on the class of the same name in Spring Security, removed in
+ * Spring Security 7. This compatibility shim keeps the property-bag API
+ * (authenticationManager, accessDecisionManager, securityMetadataSource, etc.)
+ * that the Grails Spring Security plugin still relies on, so subclasses such as
+ * the plugin's filter-security and method-security interceptors continue to
+ * compile and run unchanged.
+ */
@CompileStatic
abstract class AbstractSecurityInterceptor {