From 8f28cae13b6df2666153b952e3c5dfc4a0dc3ac1 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 12:50:01 +0200 Subject: [PATCH 01/29] build: update to Java 21 --- .github/workflows/gradle.yml | 15 +++++++++------ .github/workflows/rat.yml | 7 +++++-- .github/workflows/release.yml | 2 +- .sdkmanrc | 2 +- gradle.properties | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index ee5a47234..b2938d3b8 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -27,6 +27,9 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +env: + JAVA_DISTRIBUTION: liberica + JAVA_VERSION: 21 jobs: coreTests: if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} @@ -37,8 +40,8 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v4 with: - java-version: 17 - distribution: liberica + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 with: @@ -64,8 +67,8 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v4 with: - java-version: 17 - distribution: liberica + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 with: @@ -90,8 +93,8 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v4 with: - java-version: 17 - distribution: liberica + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 with: diff --git a/.github/workflows/rat.yml b/.github/workflows/rat.yml index fed8dcbbc..c3a8f04fe 100644 --- a/.github/workflows/rat.yml +++ b/.github/workflows/rat.yml @@ -26,6 +26,9 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +env: + JAVA_DISTRIBUTION: liberica + JAVA_VERSION: 21 jobs: rat-audit: runs-on: ubuntu-latest @@ -35,8 +38,8 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v4 with: - distribution: liberica - java-version: 17 + distribution: ${{ env.JAVA_DISTRIBUTION }} + java-version: ${{ env.JAVA_VERSION }} - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5551cd6c7..83196c174 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GRAILS_PUBLISH_RELEASE: true JAVA_DISTRIBUTION: liberica - JAVA_VERSION: 17.0.17 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc + JAVA_VERSION: 21.0.10 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc PROJECT_DESC: > Apache Grails Spring Security adds production-ready authentication and authorization to Apache Grails applications. diff --git a/.sdkmanrc b/.sdkmanrc index 84839beb1..f84f394c1 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,4 +1,4 @@ -java=17.0.17-librca +java=21.0.10-librca gradle=8.14.4 # This is here to support the test app generation in the *rest projects grails=7.0.7 \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 31ec4f4e4..c0607a7f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,7 +18,7 @@ # projectVersion=8.0.0-SNAPSHOT grailsVersion=8.0.0-SNAPSHOT -javaVersion=17 +javaVersion=21 unboundidLdapSdkVersion=7.0.3 apacheDsVersion=1.5.4 From e0e518de254589b3e5a81c358eb4e2fb6856643c Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 12:50:33 +0200 Subject: [PATCH 02/29] build: remove selenium version restriction --- build.gradle | 9 --------- 1 file changed, 9 deletions(-) diff --git a/build.gradle b/build.gradle index ddc91d366..7237b1a7b 100644 --- a/build.gradle +++ b/build.gradle @@ -55,15 +55,6 @@ allprojects { } } } - - configurations.configureEach { - resolutionStrategy.eachDependency { DependencyResolveDetails details -> - if (details.requested.group == 'org.seleniumhq.selenium') { - details.useVersion('4.25.0') - details.because('Temporary workaround because of https://issues.chromium.org/issues/42323769') - } - } - } } subprojects { From 807058d304a4890ac4481d783714c9ce994b0787 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 12:51:21 +0200 Subject: [PATCH 03/29] feat: add spring security compatibility module --- gradle/publish-root-config.gradle | 1 + settings.gradle | 3 + spring-security-compat/build.gradle | 59 +++++++ .../access/LegacyAccessCompatibility.groovy | 86 +++++++++ ...redAnnotationSecurityMetadataSource.groovy | 51 ++++++ ...sionBasedAnnotationAttributeFactory.groovy | 32 ++++ ...ExpressionBasedPostInvocationAdvice.groovy | 163 ++++++++++++++++++ .../ExpressionBasedPreInvocationAdvice.groovy | 148 ++++++++++++++++ .../AbstractSecurityInterceptor.groovy | 43 +++++ .../intercept/AfterInvocationManager.groovy | 40 +++++ .../AfterInvocationProviderManager.groovy | 50 ++++++ .../access/intercept/NullRunAsManager.groovy | 33 ++++ .../RunAsImplAuthenticationProvider.groovy | 40 +++++ .../access/intercept/RunAsManager.groovy | 30 ++++ .../access/intercept/RunAsManagerImpl.groovy | 65 +++++++ .../MethodSecurityInterceptor.groovy | 71 ++++++++ ...allbackMethodSecurityMetadataSource.groovy | 74 ++++++++ ...bstractMethodSecurityMetadataSource.groovy | 30 ++++ .../MethodSecurityMetadataSource.groovy | 33 ++++ ...ssionBasedAnnotationConfigAttribute.groovy | 57 ++++++ .../PostInvocationAdviceProvider.groovy | 69 ++++++++ ...eInvocationAuthorizationAdviceVoter.groovy | 60 +++++++ ...ostAnnotationSecurityMetadataSource.groovy | 98 +++++++++++ .../vote/AbstractAccessDecisionManager.groovy | 66 +++++++ .../access/vote/AffirmativeBased.groovy | 66 +++++++ .../access/vote/AuthenticatedVoter.groovy | 76 ++++++++ .../access/vote/RoleHierarchyVoter.groovy | 57 ++++++ .../security/access/vote/RoleVoter.groovy | 61 +++++++ .../security/acls/AclEntryVoter.groovy | 149 ++++++++++++++++ ...vocationCollectionFilteringProvider.groovy | 93 ++++++++++ .../AclEntryAfterInvocationProvider.groovy | 115 ++++++++++++ .../security/web/PortResolver.groovy | 29 ++++ .../security/web/PortResolverImpl.groovy | 34 ++++ ...aultWebInvocationPrivilegeEvaluator.groovy | 44 +++++ .../channel/ChannelDecisionManagerImpl.groovy | 46 +++++ .../channel/ChannelProcessingFilter.groovy | 47 +++++ .../access/channel/ChannelProcessor.groovy | 31 ++++ .../channel/InsecureChannelProcessor.groovy | 41 +++++ .../channel/RetryWithHttpEntryPoint.groovy | 53 ++++++ .../channel/RetryWithHttpsEntryPoint.groovy | 53 ++++++ .../channel/SecureChannelProcessor.groovy | 41 +++++ ...DefaultWebSecurityExpressionHandler.groovy | 47 +++++ ...terInvocationSecurityMetadataSource.groovy | 59 +++++++ ...terInvocationSecurityMetadataSource.groovy | 31 ++++ .../FilterSecurityInterceptor.groovy | 63 +++++++ .../util/matcher/AntPathRequestMatcher.groovy | 81 +++++++++ 46 files changed, 2719 insertions(+) create mode 100644 spring-security-compat/build.gradle create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/LegacyAccessCompatibility.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/annotation/SecuredAnnotationSecurityMetadataSource.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedAnnotationAttributeFactory.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPostInvocationAdvice.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPreInvocationAdvice.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationManager.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationProviderManager.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/NullRunAsManager.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsImplAuthenticationProvider.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManager.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptor.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSource.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractMethodSecurityMetadataSource.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/method/MethodSecurityMetadataSource.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/ExpressionBasedAnnotationConfigAttribute.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PostInvocationAdviceProvider.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PreInvocationAuthorizationAdviceVoter.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PrePostAnnotationSecurityMetadataSource.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AbstractAccessDecisionManager.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AffirmativeBased.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AuthenticatedVoter.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleHierarchyVoter.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleVoter.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/acls/AclEntryVoter.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationCollectionFilteringProvider.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationProvider.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolver.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolverImpl.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/DefaultWebInvocationPrivilegeEvaluator.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelDecisionManagerImpl.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessingFilter.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessor.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/InsecureChannelProcessor.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpEntryPoint.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpsEntryPoint.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/SecureChannelProcessor.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/expression/DefaultWebSecurityExpressionHandler.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/DefaultFilterInvocationSecurityMetadataSource.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterInvocationSecurityMetadataSource.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy create mode 100644 spring-security-compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.groovy diff --git a/gradle/publish-root-config.gradle b/gradle/publish-root-config.gradle index d2fdcf65f..0ad9e720b 100644 --- a/gradle/publish-root-config.gradle +++ b/gradle/publish-root-config.gradle @@ -29,6 +29,7 @@ def publishedProjects = [ 'core-plugin', 'ldap-plugin', 'oauth2-plugin', + 'spring-security-compat', 'spring-security-rest', 'spring-security-rest-gorm', 'spring-security-rest-grailscache', diff --git a/settings.gradle b/settings.gradle index b70a2940e..fac5ea452 100644 --- a/settings.gradle +++ b/settings.gradle @@ -99,6 +99,9 @@ include 'oauth2-docs' project(':oauth2-plugin').projectDir = new File(settingsDir, 'plugin-oauth2/plugin') project(':oauth2-docs').projectDir = new File(settingsDir, 'plugin-oauth2/docs') +include 'spring-security-compat' +project(':spring-security-compat').projectDir = new File(settingsDir, 'spring-security-compat') + include 'spring-security-rest' include 'spring-security-rest-gorm' include 'spring-security-rest-grailscache' diff --git a/spring-security-compat/build.gradle b/spring-security-compat/build.gradle new file mode 100644 index 000000000..e5bdf867e --- /dev/null +++ b/spring-security-compat/build.gradle @@ -0,0 +1,59 @@ +/* + * 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. + */ +plugins { + id 'groovy' + id 'java-library' +} + +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.' + pomDevelopers = [ + matrei: 'Mattias Reichel', + ] +} + +dependencies { + implementation platform("org.apache.grails:grails-bom:$grailsVersion") + + api 'org.springframework.security:spring-security-core' + api 'org.springframework.security:spring-security-web' + + implementation 'org.springframework.security:spring-security-acl' + implementation 'org.springframework:spring-aop' + implementation 'org.springframework:spring-beans' + implementation 'org.springframework:spring-context' + implementation 'org.springframework:spring-core' + implementation 'org.springframework:spring-expression' + implementation 'org.springframework:spring-web' + + compileOnly 'jakarta.servlet:jakarta.servlet-api' + compileOnly 'org.apache.groovy:groovy' + compileOnly 'org.slf4j:slf4j-api' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/java-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/groovydoc-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/publish-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/reproducible-config.gradle') +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/LegacyAccessCompatibility.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/LegacyAccessCompatibility.groovy new file mode 100644 index 000000000..e94b6a406 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/LegacyAccessCompatibility.groovy @@ -0,0 +1,86 @@ +/* + * 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 org.springframework.security.access + +import groovy.transform.CompileStatic + +import org.springframework.security.core.Authentication + +/** + * Compatibility layer for Spring Security 7 removals used by the plugin. + */ +@CompileStatic +interface ConfigAttribute extends Serializable { + + String getAttribute() +} + +@CompileStatic +class SecurityConfig implements ConfigAttribute { + + private static final long serialVersionUID = 1L + + final String attribute + + SecurityConfig(String attribute) { + this.attribute = attribute + } + + @Override + String getAttribute() { + attribute + } + + static List createList(String... attributes) { + attributes.collect { new SecurityConfig(it) } as List + } + + @Override + String toString() { + attribute + } +} + +@CompileStatic +interface AccessDecisionVoter { + + int ACCESS_GRANTED = 1 + int ACCESS_ABSTAIN = 0 + int ACCESS_DENIED = -1 + + boolean supports(ConfigAttribute attribute) + boolean supports(Class clazz) + int vote(Authentication authentication, T object, Collection attributes) +} + +@CompileStatic +interface AccessDecisionManager { + + void decide(Authentication authentication, Object object, Collection configAttributes) + boolean supports(ConfigAttribute attribute) + boolean supports(Class clazz) +} + +@CompileStatic +interface AfterInvocationProvider { + + Object decide(Authentication authentication, Object object, Collection configAttributes, Object returnedObject) + boolean supports(ConfigAttribute attribute) + boolean supports(Class clazz) +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/annotation/SecuredAnnotationSecurityMetadataSource.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/annotation/SecuredAnnotationSecurityMetadataSource.groovy new file mode 100644 index 000000000..bb034cf5e --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/annotation/SecuredAnnotationSecurityMetadataSource.groovy @@ -0,0 +1,51 @@ +/* + * 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 org.springframework.security.access.annotation + +import java.lang.reflect.Method + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.SecurityConfig +import org.springframework.security.access.method.AbstractFallbackMethodSecurityMetadataSource + +@CompileStatic +class SecuredAnnotationSecurityMetadataSource extends AbstractFallbackMethodSecurityMetadataSource { + + @Override + protected Collection findAttributes(Class clazz) { + processAnnotation(clazz.getAnnotation(Secured)) + } + + @Override + protected Collection findAttributes(Method method, Class targetClass) { + processAnnotation(method.getAnnotation(Secured)) + } + + @Override + Collection getAllConfigAttributes() { + Collections.emptyList() + } + + private static Collection processAnnotation(Secured secured) { + secured == null ? null : secured.value().collect { String token -> new SecurityConfig(token) } as List + } +} + diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedAnnotationAttributeFactory.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedAnnotationAttributeFactory.groovy new file mode 100644 index 000000000..e32a4ec3b --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedAnnotationAttributeFactory.groovy @@ -0,0 +1,32 @@ +/* + * 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 org.springframework.security.access.expression.method + +import groovy.transform.CompileStatic + +@CompileStatic +class ExpressionBasedAnnotationAttributeFactory { + + final DefaultMethodSecurityExpressionHandler expressionHandler + + ExpressionBasedAnnotationAttributeFactory(DefaultMethodSecurityExpressionHandler expressionHandler) { + this.expressionHandler = expressionHandler + } +} + diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPostInvocationAdvice.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPostInvocationAdvice.groovy new file mode 100644 index 000000000..64a714328 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPostInvocationAdvice.groovy @@ -0,0 +1,163 @@ +/* + * 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 org.springframework.security.access.expression.method + +import groovy.transform.CompileStatic + +import org.aopalliance.intercept.MethodInvocation + +import org.springframework.expression.EvaluationContext +import org.springframework.expression.ExpressionParser +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.expression.spel.support.StandardEvaluationContext +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.access.PermissionEvaluator +import org.springframework.security.access.prepost.ExpressionBasedAnnotationConfigAttribute +import org.springframework.security.core.Authentication + +@CompileStatic +class ExpressionBasedPostInvocationAdvice { + + final DefaultMethodSecurityExpressionHandler expressionHandler + + private final ExpressionParser expressionParser = new SpelExpressionParser() + + ExpressionBasedPostInvocationAdvice(DefaultMethodSecurityExpressionHandler expressionHandler) { + this.expressionHandler = expressionHandler + } + + Object after(Authentication authentication, MethodInvocation invocation, Object attribute, Object returnedObject) { + if (!(attribute instanceof ExpressionBasedAnnotationConfigAttribute)) { + return returnedObject + } + + def expressionAttribute = attribute as ExpressionBasedAnnotationConfigAttribute + def filteredObject = applyPostFilter(authentication, expressionAttribute, returnedObject) + applyPostAuthorize(authentication, expressionAttribute, filteredObject) + filteredObject + } + + private Object applyPostFilter( + Authentication authentication, + ExpressionBasedAnnotationConfigAttribute expressionAttribute, + Object returnedObject + ) { + if (expressionAttribute.postFilterExpression == null || !(returnedObject instanceof Collection)) { + return returnedObject + } + + def collection = returnedObject as Collection + List filtered = [] + for (def candidate : collection) { + def context = createEvaluationContext(authentication, candidate, returnedObject) + def allowed = expressionParser.parseExpression(expressionAttribute.postFilterExpression) + .getValue(context, Boolean) + if (Boolean.TRUE == allowed) { + filtered << candidate + } + } + filtered + } + + private void applyPostAuthorize( + Authentication authentication, + ExpressionBasedAnnotationConfigAttribute expressionAttribute, + Object returnedObject + ) { + if (expressionAttribute.postAuthorizeExpression == null) { + return + } + + def context = createEvaluationContext(authentication, null, returnedObject) + def allowed = expressionParser.parseExpression(expressionAttribute.postAuthorizeExpression) + .getValue(context, Boolean) + if (Boolean.TRUE != allowed) { + throw new AccessDeniedException('Access is denied') + } + } + + private EvaluationContext createEvaluationContext(Authentication authentication, Object filterObject, Object returnObject) { + def permissionEvaluator = resolvePermissionEvaluator() + def root = new MethodSecurityExpressionRoot(authentication, permissionEvaluator).tap { + it.filterObject = filterObject + it.returnObject = returnObject + } + new StandardEvaluationContext(root) + } + + private PermissionEvaluator resolvePermissionEvaluator() { + if (expressionHandler == null) { + return null + } + + def current = expressionHandler.class + while (current != null) { + try { + def method = current.getDeclaredMethod('getPermissionEvaluator').tap { + accessible = true + } + return (PermissionEvaluator) method.invoke(expressionHandler) + } + catch (NoSuchMethodException ignored) { + } + + try { + def field = current.getDeclaredField('permissionEvaluator').tap { + accessible = true + } + return (PermissionEvaluator) field.get(expressionHandler) + } + catch (NoSuchFieldException ignored) { + } + + current = current.superclass + } + + return null + } + + private static class MethodSecurityExpressionRoot { + + final Authentication authentication + final PermissionEvaluator permissionEvaluator + final Object read = 1 + final Object write = 2 + final Object create = 4 + final Object delete = 8 + final Object admin = 16 + + Object filterObject + Object returnObject + + MethodSecurityExpressionRoot(Authentication authentication, PermissionEvaluator permissionEvaluator) { + this.authentication = authentication + this.permissionEvaluator = permissionEvaluator + } + + boolean hasPermission(Object target, Object permission) { + permissionEvaluator != null && authentication != null && + permissionEvaluator.hasPermission(authentication, target, permission) + } + + boolean hasPermission(Object targetId, String targetType, Object permission) { + permissionEvaluator != null && authentication != null && + permissionEvaluator.hasPermission(authentication, (Serializable) targetId, targetType, permission) + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPreInvocationAdvice.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPreInvocationAdvice.groovy new file mode 100644 index 000000000..7366a91ec --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/expression/method/ExpressionBasedPreInvocationAdvice.groovy @@ -0,0 +1,148 @@ +/* + * 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 org.springframework.security.access.expression.method + +import java.lang.reflect.Method + +import groovy.transform.CompileStatic + +import org.aopalliance.intercept.MethodInvocation + +import org.springframework.expression.EvaluationContext +import org.springframework.expression.ExpressionParser +import org.springframework.expression.spel.standard.SpelExpressionParser +import org.springframework.expression.spel.support.StandardEvaluationContext +import org.springframework.security.access.PermissionEvaluator +import org.springframework.security.access.prepost.ExpressionBasedAnnotationConfigAttribute +import org.springframework.security.core.Authentication +import org.springframework.security.core.parameters.P + +@CompileStatic +class ExpressionBasedPreInvocationAdvice { + + DefaultMethodSecurityExpressionHandler expressionHandler + + private final ExpressionParser expressionParser = new SpelExpressionParser() + + boolean before(Authentication authentication, MethodInvocation invocation, Object attribute) { + if (!(attribute instanceof ExpressionBasedAnnotationConfigAttribute)) { + return true + } + + def expressionAttribute = attribute as ExpressionBasedAnnotationConfigAttribute + if (expressionAttribute.preAuthorizeExpression == null) { + return true + } + + def context = createEvaluationContext(authentication, invocation) + def allowed = expressionParser + .parseExpression(expressionAttribute.preAuthorizeExpression) + .getValue(context, Boolean) + Boolean.TRUE == allowed + } + + private EvaluationContext createEvaluationContext(Authentication authentication, MethodInvocation invocation) { + def permissionEvaluator = resolvePermissionEvaluator() + def root = new MethodSecurityExpressionRoot(authentication, permissionEvaluator) + def context = new StandardEvaluationContext(root) + bindMethodArguments(context, invocation.method, invocation.arguments) + context + } + + private PermissionEvaluator resolvePermissionEvaluator() { + if (expressionHandler == null) { + return null + } + + def current = expressionHandler.class + while (current != null) { + try { + def method = current.getDeclaredMethod('getPermissionEvaluator').tap { + accessible = true + } + return (PermissionEvaluator) method.invoke(expressionHandler) + } + catch (NoSuchMethodException ignored) { + } + + try { + def field = current.getDeclaredField('permissionEvaluator').tap { + accessible = true + } + return (PermissionEvaluator) field.get(expressionHandler) + } + catch (NoSuchFieldException ignored) { + } + + current = current.superclass + } + + return null + } + + private static void bindMethodArguments(StandardEvaluationContext context, Method method, Object[] arguments) { + def parameterAnnotations = method.parameterAnnotations + for (int i = 0; i < parameterAnnotations.length; i++) { + for (def annotation : parameterAnnotations[i]) { + if (annotation instanceof P) { + context.setVariable(((P) annotation).value(), arguments[i]) + } + } + } + } + + private static class MethodSecurityExpressionRoot { + final Authentication authentication + final PermissionEvaluator permissionEvaluator + final Object read = 1 + final Object write = 2 + final Object create = 4 + final Object delete = 8 + final Object admin = 16 + + MethodSecurityExpressionRoot(Authentication authentication, PermissionEvaluator permissionEvaluator) { + this.authentication = authentication + this.permissionEvaluator = permissionEvaluator + } + + boolean hasRole(String role) { + authentication?.authorities?.any {grantedAuthority -> + grantedAuthority.authority == role + } + } + + boolean hasPermission(Object target, Object permission) { + permissionEvaluator != null && authentication != null && + permissionEvaluator.hasPermission(authentication, target, permission) + } + + boolean hasPermission(Object targetId, String targetType, Object permission) { + permissionEvaluator != null && authentication != null && + permissionEvaluator.hasPermission(authentication, (Serializable) targetId, targetType, permission) + } + } +} + + + + + + + + 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 new file mode 100644 index 000000000..ba04584d2 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy @@ -0,0 +1,43 @@ +/* + * 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 org.springframework.security.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.AccessDecisionManager +import org.springframework.security.authentication.AuthenticationManager + +@CompileStatic +abstract class AbstractSecurityInterceptor { + + AuthenticationManager authenticationManager + AccessDecisionManager accessDecisionManager + Object securityMetadataSource + Object runAsManager + AfterInvocationManager afterInvocationManager + boolean alwaysReauthenticate + boolean rejectPublicInvocations + boolean validateConfigAttributes + boolean publishAuthorizationSuccess + boolean observeOncePerRequest = true + + Object obtainSecurityMetadataSource() { + securityMetadataSource + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationManager.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationManager.groovy new file mode 100644 index 000000000..cb1359760 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationManager.groovy @@ -0,0 +1,40 @@ +/* + * 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 org.springframework.security.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication + +@CompileStatic +interface AfterInvocationManager { + + Object decide( + Authentication authentication, + Object object, + Collection configAttributes, + Object returnedObject + ) + + boolean supports(ConfigAttribute attribute) + + boolean supports(Class clazz) +} + diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationProviderManager.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationProviderManager.groovy new file mode 100644 index 000000000..9dff35262 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AfterInvocationProviderManager.groovy @@ -0,0 +1,50 @@ +/* + * 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 org.springframework.security.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.AfterInvocationProvider +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication + +@CompileStatic +class AfterInvocationProviderManager implements AfterInvocationManager { + + List providers = [] + + @Override + Object decide(Authentication authentication, Object object, Collection configAttributes, Object returnedObject) { + Object result = returnedObject + for (def provider in providers) { + result = provider.decide(authentication, object, configAttributes, result) + } + result + } + + @Override + boolean supports(ConfigAttribute attribute) { + providers.any { AfterInvocationProvider provider -> provider.supports(attribute) } + } + + @Override + boolean supports(Class clazz) { + providers.every { AfterInvocationProvider provider -> provider.supports(clazz) } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/NullRunAsManager.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/NullRunAsManager.groovy new file mode 100644 index 000000000..5e47b35e3 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/NullRunAsManager.groovy @@ -0,0 +1,33 @@ +/* + * 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 org.springframework.security.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication + +@CompileStatic +class NullRunAsManager implements RunAsManager { + + @Override + Authentication buildRunAs(Authentication authentication, Object object, Collection attributes) { + null + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsImplAuthenticationProvider.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsImplAuthenticationProvider.groovy new file mode 100644 index 000000000..2d9057aed --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsImplAuthenticationProvider.groovy @@ -0,0 +1,40 @@ +/* + * 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 org.springframework.security.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.core.Authentication + +@CompileStatic +class RunAsImplAuthenticationProvider implements AuthenticationProvider { + + String key + + @Override + Authentication authenticate(Authentication authentication) { + authentication + } + + @Override + boolean supports(Class authentication) { + true + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManager.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManager.groovy new file mode 100644 index 000000000..d92aadb60 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManager.groovy @@ -0,0 +1,30 @@ +/* + * 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 org.springframework.security.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication + +@CompileStatic +interface RunAsManager { + + Authentication buildRunAs(Authentication authentication, Object object, Collection attributes) +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy new file mode 100644 index 000000000..2b5c86a3a --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/RunAsManagerImpl.groovy @@ -0,0 +1,65 @@ +/* + * 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 org.springframework.security.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority + +@CompileStatic +class RunAsManagerImpl implements RunAsManager { + + String key + String rolePrefix = 'ROLE_' + String runAsPrefix = 'RUN_AS_' + + @Override + Authentication buildRunAs(Authentication authentication, Object object, Collection attributes) { + if (authentication == null || attributes == null) { + return null + } + + def runAsAuthorities = attributes + .findAll { it?.attribute?.startsWith(runAsPrefix) } + .collect { new SimpleGrantedAuthority(rolePrefix + it.attribute) } as List + + if (!runAsAuthorities) { + return null + } + + def currentAuthorities = authentication.authorities == null ? + Collections.emptyList() : + authentication.authorities + def mergedAuthorities = new LinkedHashSet(currentAuthorities).tap { + addAll(runAsAuthorities) + } + + def runAsAuthentication = new UsernamePasswordAuthenticationToken( + authentication.principal, + authentication.credentials, + new ArrayList(mergedAuthorities) + ) + runAsAuthentication.details = authentication.details + runAsAuthentication + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptor.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptor.groovy new file mode 100644 index 000000000..cb3325b0a --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptor.groovy @@ -0,0 +1,71 @@ +/* + * 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 org.springframework.security.access.intercept.aopalliance + +import groovy.transform.CompileStatic + +import org.aopalliance.intercept.MethodInterceptor +import org.aopalliance.intercept.MethodInvocation + +import org.springframework.security.access.intercept.AbstractSecurityInterceptor +import org.springframework.security.access.intercept.RunAsManager +import org.springframework.security.access.method.MethodSecurityMetadataSource +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException +import org.springframework.security.core.context.SecurityContextHolder + +@CompileStatic +class MethodSecurityInterceptor extends AbstractSecurityInterceptor implements MethodInterceptor { + + @Override + Object invoke(MethodInvocation invocation) throws Throwable { + def metadataSource = securityMetadataSource as MethodSecurityMetadataSource + def attributes = metadataSource?.getAttributes(invocation.method, invocation.this?.class) + if (attributes == null) { + return invocation.proceed() + } + + def authentication = SecurityContextHolder.context?.authentication + if (authentication == null) { + throw new AuthenticationCredentialsNotFoundException( + 'An Authentication object was not found in the SecurityContext' + ) + } + accessDecisionManager?.decide(authentication, invocation, attributes) + + def runAsAuthentication = (runAsManager instanceof RunAsManager) ? + ((RunAsManager) runAsManager).buildRunAs(authentication, invocation, attributes) : null + def activeAuthentication = runAsAuthentication ?: authentication + + if (runAsAuthentication != null) { + SecurityContextHolder.context.authentication = runAsAuthentication + } + + try { + def returnedObject = invocation.proceed() + return afterInvocationManager == null ? + returnedObject : + afterInvocationManager.decide(activeAuthentication, invocation, attributes, returnedObject) + } + finally { + if (runAsAuthentication != null) { + SecurityContextHolder.context.authentication = authentication + } + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSource.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSource.groovy new file mode 100644 index 000000000..d8e0e9f7e --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSource.groovy @@ -0,0 +1,74 @@ +/* + * 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 org.springframework.security.access.method + +import java.lang.reflect.Method + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.util.ClassUtils + +@CompileStatic +abstract class AbstractFallbackMethodSecurityMetadataSource extends AbstractMethodSecurityMetadataSource { + + @Override + Collection getAttributes(Method method, Class targetClass) { + def specificMethod = targetClass == null ? + method : + ClassUtils.getMostSpecificMethod(method, targetClass) + + def attributes = findAttributes(specificMethod, targetClass) + if (attributes != null) { + return attributes + } + + def declaringClass = specificMethod?.declaringClass + if (declaringClass != null) { + attributes = findAttributes(declaringClass) + if (attributes != null) { + return attributes + } + } + + if (specificMethod != method) { + attributes = findAttributes(method, method?.declaringClass) + if (attributes != null) { + return attributes + } + + declaringClass = method?.declaringClass + if (declaringClass != null) { + attributes = findAttributes(declaringClass) + if (attributes != null) { + return attributes + } + } + } + + if (targetClass != null) { + return findAttributes(targetClass) + } + null + } + + protected abstract Collection findAttributes(Class clazz) + + protected abstract Collection findAttributes(Method method, Class targetClass) +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractMethodSecurityMetadataSource.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractMethodSecurityMetadataSource.groovy new file mode 100644 index 000000000..975fef496 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/method/AbstractMethodSecurityMetadataSource.groovy @@ -0,0 +1,30 @@ +/* + * 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 org.springframework.security.access.method + +import groovy.transform.CompileStatic + +@CompileStatic +abstract class AbstractMethodSecurityMetadataSource implements MethodSecurityMetadataSource { + + @Override + boolean supports(Class clazz) { + true + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/method/MethodSecurityMetadataSource.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/method/MethodSecurityMetadataSource.groovy new file mode 100644 index 000000000..cf20261d0 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/method/MethodSecurityMetadataSource.groovy @@ -0,0 +1,33 @@ +/* + * 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 org.springframework.security.access.method + +import groovy.transform.CompileStatic +import org.springframework.security.access.ConfigAttribute + +import java.lang.reflect.Method + +@CompileStatic +interface MethodSecurityMetadataSource { + + Collection getAttributes(Method method, Class targetClass) + Collection getAllConfigAttributes() + boolean supports(Class clazz) +} + diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/ExpressionBasedAnnotationConfigAttribute.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/ExpressionBasedAnnotationConfigAttribute.groovy new file mode 100644 index 000000000..be36ebcda --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/ExpressionBasedAnnotationConfigAttribute.groovy @@ -0,0 +1,57 @@ +/* + * 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 org.springframework.security.access.prepost + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute + +@CompileStatic +class ExpressionBasedAnnotationConfigAttribute implements ConfigAttribute { + + final String preAuthorizeExpression + final String preFilterExpression + final String postAuthorizeExpression + final String postFilterExpression + + ExpressionBasedAnnotationConfigAttribute( + String preAuthorizeExpression, + String preFilterExpression, + String postAuthorizeExpression, + String postFilterExpression + ) { + this.preAuthorizeExpression = preAuthorizeExpression + this.preFilterExpression = preFilterExpression + this.postAuthorizeExpression = postAuthorizeExpression + this.postFilterExpression = postFilterExpression + } + + boolean hasPreInvocationExpression() { + preAuthorizeExpression != null || preFilterExpression != null + } + + boolean hasPostInvocationExpression() { + postAuthorizeExpression != null || postFilterExpression != null + } + + @Override + String getAttribute() { + 'EXPRESSION_BASED_ANNOTATION' + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PostInvocationAdviceProvider.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PostInvocationAdviceProvider.groovy new file mode 100644 index 000000000..bcb34fc32 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PostInvocationAdviceProvider.groovy @@ -0,0 +1,69 @@ +/* + * 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 org.springframework.security.access.prepost + +import groovy.transform.CompileStatic + +import org.aopalliance.intercept.MethodInvocation + +import org.springframework.security.access.AfterInvocationProvider +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.expression.method.ExpressionBasedPostInvocationAdvice +import org.springframework.security.core.Authentication + +@CompileStatic +class PostInvocationAdviceProvider implements AfterInvocationProvider { + + final ExpressionBasedPostInvocationAdvice postInvocationAdvice + + PostInvocationAdviceProvider(ExpressionBasedPostInvocationAdvice postInvocationAdvice) { + this.postInvocationAdvice = postInvocationAdvice + } + + @Override + Object decide( + Authentication authentication, + Object object, + Collection configAttributes, + Object returnedObject + ) { + if (!(object instanceof MethodInvocation)) { + return returnedObject + } + + def attribute = configAttributes.find { + supports(it) + } as ExpressionBasedAnnotationConfigAttribute + + attribute == null ? + returnedObject : + postInvocationAdvice.after(authentication, (MethodInvocation) object, attribute, returnedObject) + } + + @Override + boolean supports(ConfigAttribute attribute) { + attribute instanceof ExpressionBasedAnnotationConfigAttribute && + ((ExpressionBasedAnnotationConfigAttribute) attribute).hasPostInvocationExpression() + } + + @Override + boolean supports(Class clazz) { + true + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PreInvocationAuthorizationAdviceVoter.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PreInvocationAuthorizationAdviceVoter.groovy new file mode 100644 index 000000000..8c1e285ce --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PreInvocationAuthorizationAdviceVoter.groovy @@ -0,0 +1,60 @@ +/* + * 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 org.springframework.security.access.prepost + +import groovy.transform.CompileStatic + +import org.aopalliance.intercept.MethodInvocation + +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.expression.method.ExpressionBasedPreInvocationAdvice +import org.springframework.security.core.Authentication + +@CompileStatic +class PreInvocationAuthorizationAdviceVoter implements AccessDecisionVoter { + + final ExpressionBasedPreInvocationAdvice preInvocationAdvice + + PreInvocationAuthorizationAdviceVoter(ExpressionBasedPreInvocationAdvice preInvocationAdvice) { + this.preInvocationAdvice = preInvocationAdvice + } + + @Override + boolean supports(ConfigAttribute attribute) { + attribute instanceof ExpressionBasedAnnotationConfigAttribute && + ((ExpressionBasedAnnotationConfigAttribute) attribute).hasPreInvocationExpression() + } + + @Override + boolean supports(Class clazz) { + MethodInvocation.isAssignableFrom(clazz) + } + + @Override + int vote(Authentication authentication, MethodInvocation object, Collection attributes) { + def attribute = attributes.find { + supports(it) + } + if (attribute == null) { + return ACCESS_ABSTAIN + } + preInvocationAdvice.before(authentication, object, attribute) ? ACCESS_GRANTED : ACCESS_DENIED + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PrePostAnnotationSecurityMetadataSource.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PrePostAnnotationSecurityMetadataSource.groovy new file mode 100644 index 000000000..08672ba79 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/prepost/PrePostAnnotationSecurityMetadataSource.groovy @@ -0,0 +1,98 @@ +/* + * 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 org.springframework.security.access.prepost + +import java.lang.annotation.Annotation +import java.lang.reflect.Method + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.expression.method.ExpressionBasedAnnotationAttributeFactory +import org.springframework.security.access.method.AbstractMethodSecurityMetadataSource +import org.springframework.util.ClassUtils + +@CompileStatic +class PrePostAnnotationSecurityMetadataSource extends AbstractMethodSecurityMetadataSource { + + final ExpressionBasedAnnotationAttributeFactory attributeFactory + + PrePostAnnotationSecurityMetadataSource(ExpressionBasedAnnotationAttributeFactory attributeFactory) { + this.attributeFactory = attributeFactory + } + + @Override + Collection getAttributes(Method method, Class targetClass) { + def specificMethod = targetClass == null ? method : ClassUtils.getMostSpecificMethod(method, targetClass) + def preAuthorize = findAnnotationValue(PreAuthorize, specificMethod, method, targetClass) + def preFilter = findAnnotationValue(PreFilter, specificMethod, method, targetClass) + def postAuthorize = findAnnotationValue(PostAuthorize, specificMethod, method, targetClass) + def postFilter = findAnnotationValue(PostFilter, specificMethod, method, targetClass) + if (preAuthorize == null && preFilter == null && postAuthorize == null && postFilter == null) { + return null + } + + Collections.singletonList( + new ExpressionBasedAnnotationConfigAttribute( + preAuthorize, preFilter, postAuthorize, postFilter + ) + ) + } + + @Override + Collection getAllConfigAttributes() { + Collections.emptyList() + } + + private static String findAnnotationValue(Class annotationType, Method specificMethod, Method originalMethod, Class targetClass) { + def value = annotationValue(specificMethod, annotationType) + if (value != null) { + return value + } + value = annotationValue(specificMethod?.declaringClass, annotationType) + if (value != null) { + return value + } + if (specificMethod != originalMethod) { + value = annotationValue(originalMethod, annotationType) + if (value != null) { + return value + } + value = annotationValue(originalMethod?.declaringClass, annotationType) + if (value != null) { + return value + } + } + annotationValue(targetClass, annotationType) + } + + private static String annotationValue(Method method, Class annotationType) { + readAnnotationValue(method?.getAnnotation(annotationType)) + } + + private static String annotationValue(Class type, Class annotationType) { + readAnnotationValue(type?.getAnnotation(annotationType)) + } + + private static String readAnnotationValue(Annotation annotation) { + annotation == null ? + null : + (String) annotation.annotationType().getMethod('value').invoke(annotation) + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AbstractAccessDecisionManager.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AbstractAccessDecisionManager.groovy new file mode 100644 index 000000000..bf9eb87be --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AbstractAccessDecisionManager.groovy @@ -0,0 +1,66 @@ +/* + * 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 org.springframework.security.access.vote + +import groovy.transform.CompileStatic + +import org.springframework.context.support.MessageSourceAccessor +import org.springframework.security.access.AccessDecisionManager +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.SpringSecurityMessageSource + +@CompileStatic +abstract class AbstractAccessDecisionManager implements AccessDecisionManager { + + protected final MessageSourceAccessor messages = SpringSecurityMessageSource.accessor + protected final List decisionVoters + + boolean allowIfAllAbstainDecisions + + AbstractAccessDecisionManager(List decisionVoters) { + this.decisionVoters = decisionVoters == null ? [] : new ArrayList<>(decisionVoters) + } + + List getDecisionVoters() { + decisionVoters + } + + @Override + boolean supports(ConfigAttribute attribute) { + decisionVoters.any { it.supports(attribute) } + } + + @Override + boolean supports(Class clazz) { + decisionVoters.every { it.supports(clazz) } + } + + protected void checkAllowIfAllAbstainDecisions() { + if (!allowIfAllAbstainDecisions) { + throw new AccessDeniedException( + messages.getMessage( + 'AbstractAccessDecisionManager.accessDenied', + 'Access is denied' + ) + ) + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AffirmativeBased.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AffirmativeBased.groovy new file mode 100644 index 000000000..2c504da2c --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AffirmativeBased.groovy @@ -0,0 +1,66 @@ +/* + * 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 org.springframework.security.access.vote + +import groovy.transform.CompileStatic + +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication + +@CompileStatic +class AffirmativeBased extends AbstractAccessDecisionManager { + + AffirmativeBased(List decisionVoters) { + super(decisionVoters) + } + + @Override + void decide(Authentication authentication, Object object, Collection configAttributes) { + boolean granted = false + int denyCount = 0 + for (def voter : decisionVoters) { + if (object != null && !voter.supports(object.getClass())) { + continue + } + int result = voter.vote(authentication, object, configAttributes) + switch (result) { + case AccessDecisionVoter.ACCESS_GRANTED: + granted = true + break + case AccessDecisionVoter.ACCESS_DENIED: + denyCount++ + break + } + } + if (granted) { + return + } + if (denyCount > 0) { + throw new AccessDeniedException( + messages.getMessage( + 'AbstractAccessDecisionManager.accessDenied', + 'Access is denied' + ) + ) + } + checkAllowIfAllAbstainDecisions() + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AuthenticatedVoter.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AuthenticatedVoter.groovy new file mode 100644 index 000000000..8043037a2 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/AuthenticatedVoter.groovy @@ -0,0 +1,76 @@ +/* + * 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 org.springframework.security.access.vote + +import groovy.transform.CompileStatic + +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.authentication.AuthenticationTrustResolver +import org.springframework.security.authentication.AuthenticationTrustResolverImpl +import org.springframework.security.core.Authentication + +@CompileStatic +class AuthenticatedVoter implements AccessDecisionVoter { + + static final String IS_AUTHENTICATED_FULLY = 'IS_AUTHENTICATED_FULLY' + static final String IS_AUTHENTICATED_REMEMBERED = 'IS_AUTHENTICATED_REMEMBERED' + static final String IS_AUTHENTICATED_ANONYMOUSLY = 'IS_AUTHENTICATED_ANONYMOUSLY' + + AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl() + + @Override + boolean supports(ConfigAttribute attribute) { + attribute?.attribute in [IS_AUTHENTICATED_FULLY, IS_AUTHENTICATED_REMEMBERED, IS_AUTHENTICATED_ANONYMOUSLY] + } + + @Override + boolean supports(Class clazz) { + true + } + + @Override + int vote(Authentication authentication, Object object, Collection attributes) { + int result = ACCESS_ABSTAIN + for (def attribute : attributes) { + if (!supports(attribute)) { + continue + } + result = ACCESS_DENIED + if (IS_AUTHENTICATED_ANONYMOUSLY == attribute.attribute) { + return ACCESS_GRANTED + } + if (authentication == null) { + continue + } + if (IS_AUTHENTICATED_REMEMBERED == attribute.attribute && authenticationTrustResolver.isRememberMe(authentication)) { + return ACCESS_GRANTED + } + if (authentication.isAuthenticated()) { + if (IS_AUTHENTICATED_FULLY == attribute.attribute && !authenticationTrustResolver.isAnonymous(authentication) && !authenticationTrustResolver.isRememberMe(authentication)) { + return ACCESS_GRANTED + } + if (IS_AUTHENTICATED_REMEMBERED == attribute.attribute && !authenticationTrustResolver.isAnonymous(authentication)) { + return ACCESS_GRANTED + } + } + } + result + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleHierarchyVoter.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleHierarchyVoter.groovy new file mode 100644 index 000000000..dda22e933 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleHierarchyVoter.groovy @@ -0,0 +1,57 @@ +/* + * 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 org.springframework.security.access.vote + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.hierarchicalroles.RoleHierarchy +import org.springframework.security.core.Authentication + +@CompileStatic +class RoleHierarchyVoter extends RoleVoter { + + final RoleHierarchy roleHierarchy + + RoleHierarchyVoter(RoleHierarchy roleHierarchy) { + this.roleHierarchy = roleHierarchy + } + + @Override + int vote(Authentication authentication, Object object, Collection attributes) { + if (authentication == null) { + return ACCESS_DENIED + } + def reachable = roleHierarchy == null ? + authentication.authorities : + roleHierarchy.getReachableGrantedAuthorities(authentication.authorities) + def authorities = reachable*.authority as Set + int result = ACCESS_ABSTAIN + for (def attribute : attributes) { + if (!supports(attribute)) { + continue + } + result = ACCESS_DENIED + if (authorities.contains(attribute.attribute)) { + return ACCESS_GRANTED + } + } + result + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleVoter.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleVoter.groovy new file mode 100644 index 000000000..97fcc5db1 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/vote/RoleVoter.groovy @@ -0,0 +1,61 @@ +/* + * 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 org.springframework.security.access.vote + +import groovy.transform.CompileStatic + +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication + +@CompileStatic +class RoleVoter implements AccessDecisionVoter { + + String rolePrefix = 'ROLE_' + + @Override + boolean supports(ConfigAttribute attribute) { + String candidate = attribute?.attribute + candidate != null && candidate.startsWith(rolePrefix) + } + + @Override + boolean supports(Class clazz) { + true + } + + @Override + int vote(Authentication authentication, Object object, Collection attributes) { + if (authentication == null) { + return ACCESS_DENIED + } + int result = ACCESS_ABSTAIN + def authorities = authentication.authorities*.authority as Set + for (def attribute : attributes) { + if (!supports(attribute)) { + continue + } + result = ACCESS_DENIED + if (authorities.contains(attribute.attribute)) { + return ACCESS_GRANTED + } + } + result + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/acls/AclEntryVoter.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/acls/AclEntryVoter.groovy new file mode 100644 index 000000000..eef646777 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/acls/AclEntryVoter.groovy @@ -0,0 +1,149 @@ +/* + * 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 org.springframework.security.acls + +import java.lang.reflect.InvocationTargetException + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.aopalliance.intercept.MethodInvocation + +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.AuthorizationServiceException +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.acls.domain.ObjectIdentityRetrievalStrategyImpl +import org.springframework.security.acls.domain.SidRetrievalStrategyImpl +import org.springframework.security.acls.model.Acl +import org.springframework.security.acls.model.AclService +import org.springframework.security.acls.model.NotFoundException +import org.springframework.security.acls.model.ObjectIdentityRetrievalStrategy +import org.springframework.security.acls.model.Permission +import org.springframework.security.acls.model.SidRetrievalStrategy +import org.springframework.security.core.Authentication +import org.springframework.util.Assert +import org.springframework.util.ObjectUtils +import org.springframework.util.StringUtils + +@Slf4j +@CompileStatic +class AclEntryVoter implements AccessDecisionVoter { + + private final AclService aclService + private final String processConfigAttribute + private final List requirePermission + + ObjectIdentityRetrievalStrategy objectIdentityRetrievalStrategy = new ObjectIdentityRetrievalStrategyImpl() + SidRetrievalStrategy sidRetrievalStrategy = new SidRetrievalStrategyImpl() + Class processDomainObjectClass = Object + String internalMethod + + AclEntryVoter(AclService aclService, String processConfigAttribute, Permission[] requirePermission) { + Assert.notNull(processConfigAttribute, 'A processConfigAttribute is mandatory') + Assert.notNull(aclService, 'An AclService is mandatory') + Assert.isTrue(!ObjectUtils.isEmpty(requirePermission), 'One or more requirePermission entries is mandatory') + this.aclService = aclService + this.processConfigAttribute = processConfigAttribute + this.requirePermission = Arrays.asList(requirePermission) + } + + AclEntryVoter(AclService aclService, String processConfigAttribute, List requirePermission) { + this(aclService, processConfigAttribute, requirePermission as Permission[]) + } + + @Override + boolean supports(ConfigAttribute attribute) { + attribute?.attribute != null && attribute.attribute == processConfigAttribute + } + + @Override + boolean supports(Class clazz) { + MethodInvocation.isAssignableFrom(clazz) + } + + @Override + int vote(Authentication authentication, MethodInvocation object, Collection attributes) { + for (def attr : attributes) { + if (!supports(attr)) { + continue + } + def domainObject = getDomainObjectInstance(object) + if (domainObject == null) { + log.debug('Voting to abstain - domainObject is null') + return ACCESS_ABSTAIN + } + if (StringUtils.hasText(internalMethod)) { + domainObject = invokeInternalMethod(domainObject) + } + def objectIdentity = objectIdentityRetrievalStrategy.getObjectIdentity(domainObject) + def sids = sidRetrievalStrategy.getSids(authentication) + Acl acl + try { + acl = aclService.readAclById(objectIdentity, sids) + } + catch (NotFoundException ignored) { + log.debug('Voting to deny access - no ACLs apply for this principal') + return ACCESS_DENIED + } + try { + if (acl.isGranted(requirePermission, sids, false)) { + log.debug('Voting to grant access') + return ACCESS_GRANTED + } + log.debug('Voting to deny access - ACLs returned, but insufficient permissions for this principal') + return ACCESS_DENIED + } + catch (NotFoundException ignored) { + log.debug('Voting to deny access - no ACLs apply for this principal') + return ACCESS_DENIED + } + } + ACCESS_ABSTAIN + } + + protected Object getDomainObjectInstance(MethodInvocation invocation) { + for (def arg : invocation.arguments) { + if (arg != null && processDomainObjectClass.isAssignableFrom(arg.getClass())) { + return arg + } + } + throw new AuthorizationServiceException( + "MethodInvocation: $invocation did not provide any argument of type: ${processDomainObjectClass.name}" + ) + } + + private Object invokeInternalMethod(Object domainObject) { + try { + def method = domainObject.getClass().getMethod(internalMethod) + return method.invoke(domainObject) + } + catch (NoSuchMethodException ignored) { + throw new AuthorizationServiceException( + "Object of class '${domainObject.getClass()}' does not provide " + + "the requested internalMethod: $internalMethod" + ) + } + catch (IllegalAccessException | InvocationTargetException ex) { + log.debug('Problem invoking internalMethod', ex) + throw new AuthorizationServiceException( + "Problem invoking internalMethod: $internalMethod for object: $domainObject" + ) + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationCollectionFilteringProvider.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationCollectionFilteringProvider.groovy new file mode 100644 index 000000000..5115453aa --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationCollectionFilteringProvider.groovy @@ -0,0 +1,93 @@ +/* + * 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 org.springframework.security.acls.afterinvocation + +import java.lang.reflect.Array + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.security.access.AuthorizationServiceException +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.acls.model.AclService +import org.springframework.security.acls.model.Permission +import org.springframework.security.core.Authentication + +@Slf4j +@CompileStatic +class AclEntryAfterInvocationCollectionFilteringProvider extends AclEntryAfterInvocationProvider { + + AclEntryAfterInvocationCollectionFilteringProvider(AclService aclService, List requirePermission) { + super(aclService, 'AFTER_ACL_COLLECTION_READ', requirePermission) + } + + @Override + Object decide(Authentication authentication, Object object, Collection config, Object returnedObject) { + if (returnedObject == null) { + log.debug('Return object is null, skipping') + return null + } + for (def attr : config) { + if (!supports(attr)) { + continue + } + if (returnedObject instanceof Collection) { + def filtered = filterCollection(authentication, (Collection) returnedObject) + return filtered + } + if (returnedObject.getClass().isArray()) { + return filterArray(authentication, (Object[]) returnedObject) + } + throw new AuthorizationServiceException( + 'A Collection or an array (or null) was required as the returnedObject, ' + + 'but the returnedObject was: ' + returnedObject + ) + } + returnedObject + } + + private Collection filterCollection(Authentication authentication, Collection objects) { + def removeList = [] as Set + for (def domainObject : objects) { + if (domainObject == null || !processDomainObjectClass.isAssignableFrom(domainObject.getClass())) { + continue + } + if (!hasPermission(authentication, domainObject)) { + removeList << domainObject + log.debug('Principal is NOT authorised for element: {}', domainObject) + } + } + objects.removeAll(removeList) + objects + } + + private Object[] filterArray(Authentication authentication, Object[] objects) { + List filtered = [] + for (Object domainObject in objects) { + if (domainObject == null || !processDomainObjectClass.isAssignableFrom(domainObject.getClass()) || hasPermission(authentication, domainObject)) { + filtered << domainObject + } + else { + log.debug('Principal is NOT authorised for element: {}', domainObject) + } + } + def result = Array.newInstance(objects.getClass().componentType, filtered.size()) as Object[] + filtered.toArray(result) + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationProvider.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationProvider.groovy new file mode 100644 index 000000000..a9e6fc20f --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/acls/afterinvocation/AclEntryAfterInvocationProvider.groovy @@ -0,0 +1,115 @@ +/* + * 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 org.springframework.security.acls.afterinvocation + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.access.AfterInvocationProvider +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.acls.domain.ObjectIdentityRetrievalStrategyImpl +import org.springframework.security.acls.domain.SidRetrievalStrategyImpl +import org.springframework.security.acls.model.Acl +import org.springframework.security.acls.model.AclService +import org.springframework.security.acls.model.NotFoundException +import org.springframework.security.acls.model.ObjectIdentityRetrievalStrategy +import org.springframework.security.acls.model.Permission +import org.springframework.security.acls.model.SidRetrievalStrategy +import org.springframework.security.core.Authentication +import org.springframework.util.Assert + +@Slf4j +@CompileStatic +class AclEntryAfterInvocationProvider implements AfterInvocationProvider { + + final AclService aclService + final String processConfigAttribute + final List requirePermission + + ObjectIdentityRetrievalStrategy objectIdentityRetrievalStrategy = new ObjectIdentityRetrievalStrategyImpl() + SidRetrievalStrategy sidRetrievalStrategy = new SidRetrievalStrategyImpl() + Class processDomainObjectClass = Object + + AclEntryAfterInvocationProvider(AclService aclService, List requirePermission) { + this(aclService, 'AFTER_ACL_READ', requirePermission) + } + + AclEntryAfterInvocationProvider(AclService aclService, String processConfigAttribute, List requirePermission) { + Assert.notNull(aclService, 'An AclService is mandatory') + Assert.hasText(processConfigAttribute, 'A processConfigAttribute is mandatory') + Assert.notEmpty(requirePermission, 'One or more requirePermission entries is mandatory') + this.aclService = aclService + this.processConfigAttribute = processConfigAttribute + this.requirePermission = requirePermission + } + + @Override + Object decide(Authentication authentication, Object object, Collection config, Object returnedObject) { + if (returnedObject == null) { + log.debug('Return object is null, skipping') + return null + } + if (!processDomainObjectClass.isAssignableFrom(returnedObject.getClass())) { + log.debug('Return object is not applicable for this provider, skipping') + return returnedObject + } + for (def attr : config) { + if (!supports(attr)) { + continue + } + if (hasPermission(authentication, returnedObject)) { + return returnedObject + } + log.debug('Denying access') + throw new AccessDeniedException( + "Authentication ${authentication?.name} has NO permissions to the domain object $returnedObject" + ) + } + returnedObject + } + + @Override + boolean supports(ConfigAttribute attribute) { + attribute?.attribute != null && attribute.attribute == processConfigAttribute + } + + @Override + boolean supports(Class clazz) { + true + } + + protected boolean hasPermission(Authentication authentication, Object domainObject) { + def objectIdentity = objectIdentityRetrievalStrategy.getObjectIdentity(domainObject) + def sids = sidRetrievalStrategy.getSids(authentication) + Acl acl + try { + acl = aclService.readAclById(objectIdentity, sids) + } + catch (NotFoundException ignored) { + return false + } + try { + return acl.isGranted(requirePermission, sids, false) + } + catch (NotFoundException ignored) { + return false + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolver.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolver.groovy new file mode 100644 index 000000000..d141effef --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolver.groovy @@ -0,0 +1,29 @@ +/* + * 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 org.springframework.security.web + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +@CompileStatic +interface PortResolver { + + int getServerPort(HttpServletRequest request) +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolverImpl.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolverImpl.groovy new file mode 100644 index 000000000..b589b8b27 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/PortResolverImpl.groovy @@ -0,0 +1,34 @@ +/* + * 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 org.springframework.security.web + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +@CompileStatic +class PortResolverImpl implements PortResolver { + + PortMapper portMapper + + @Override + int getServerPort(HttpServletRequest request) { + request.serverPort + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/DefaultWebInvocationPrivilegeEvaluator.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/DefaultWebInvocationPrivilegeEvaluator.groovy new file mode 100644 index 000000000..2d313c669 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/DefaultWebInvocationPrivilegeEvaluator.groovy @@ -0,0 +1,44 @@ +/* + * 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 org.springframework.security.web.access + +import groovy.transform.CompileStatic + +import org.springframework.security.access.intercept.AbstractSecurityInterceptor +import org.springframework.security.core.Authentication + +@CompileStatic +class DefaultWebInvocationPrivilegeEvaluator implements WebInvocationPrivilegeEvaluator { + + protected final AbstractSecurityInterceptor interceptor + + DefaultWebInvocationPrivilegeEvaluator(AbstractSecurityInterceptor securityInterceptor) { + this.interceptor = securityInterceptor + } + + @Override + boolean isAllowed(String uri, Authentication authentication) { + isAllowed(null, uri, null, authentication) + } + + @Override + boolean isAllowed(String contextPath, String uri, String method, Authentication authentication) { + false + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelDecisionManagerImpl.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelDecisionManagerImpl.groovy new file mode 100644 index 000000000..f79fd931e --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelDecisionManagerImpl.groovy @@ -0,0 +1,46 @@ +/* + * 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 org.springframework.security.web.access.channel + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.web.FilterInvocation + +@CompileStatic +class ChannelDecisionManagerImpl { + + List channelProcessors = [] + + void decide(FilterInvocation invocation, Collection attributes) { + if (attributes == null) { + return + } + for (def attribute : attributes) { + for (def processor : channelProcessors) { + if (processor.supports(attribute)) { + processor.decide(invocation, attribute) + if (invocation.response.committed) { + return + } + } + } + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessingFilter.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessingFilter.groovy new file mode 100644 index 000000000..54be2fe4f --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessingFilter.groovy @@ -0,0 +1,47 @@ +/* + * 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 org.springframework.security.web.access.channel + +import groovy.transform.CompileStatic + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse + +import org.springframework.security.web.FilterInvocation +import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource +import org.springframework.web.filter.GenericFilterBean + +@CompileStatic +class ChannelProcessingFilter extends GenericFilterBean { + + ChannelDecisionManagerImpl channelDecisionManager + FilterInvocationSecurityMetadataSource securityMetadataSource + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + def invocation = new FilterInvocation(request, response, chain) + channelDecisionManager.decide(invocation, securityMetadataSource?.getAttributes(invocation)) + if (invocation.response.committed) { + return + } + chain.doFilter(request, response) + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessor.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessor.groovy new file mode 100644 index 000000000..f92e5d219 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/ChannelProcessor.groovy @@ -0,0 +1,31 @@ +/* + * 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 org.springframework.security.web.access.channel + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.web.FilterInvocation + +@CompileStatic +interface ChannelProcessor { + + boolean supports(ConfigAttribute attribute) + void decide(FilterInvocation invocation, ConfigAttribute attribute) +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/InsecureChannelProcessor.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/InsecureChannelProcessor.groovy new file mode 100644 index 000000000..c6288833f --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/InsecureChannelProcessor.groovy @@ -0,0 +1,41 @@ +/* + * 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 org.springframework.security.web.access.channel + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.web.FilterInvocation + +@CompileStatic +class InsecureChannelProcessor implements ChannelProcessor { + + RetryWithHttpEntryPoint entryPoint + String insecureKeyword = 'REQUIRES_INSECURE_CHANNEL' + + boolean supports(ConfigAttribute attribute) { + attribute?.attribute == insecureKeyword + } + + void decide(FilterInvocation invocation, ConfigAttribute attribute) { + if (invocation.request.secure) { + entryPoint.commence(invocation.request, invocation.response) + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpEntryPoint.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpEntryPoint.groovy new file mode 100644 index 000000000..388c5d557 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpEntryPoint.groovy @@ -0,0 +1,53 @@ +/* + * 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 org.springframework.security.web.access.channel + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.web.PortMapper +import org.springframework.security.web.PortResolver +import org.springframework.security.web.RedirectStrategy +import org.springframework.security.web.util.UrlUtils + +@CompileStatic +class RetryWithHttpEntryPoint { + + PortMapper portMapper + PortResolver portResolver + RedirectStrategy redirectStrategy + + void commence(HttpServletRequest request, HttpServletResponse response) { + def currentPort = portResolver.getServerPort(request) + def targetPort = portMapper.lookupHttpPort(currentPort) + if (targetPort == null) { + targetPort = currentPort + } + def targetUrl = UrlUtils.buildFullRequestUrl( + 'http', + request.serverName, + targetPort, + request.requestURI, + request.queryString + ) + redirectStrategy.sendRedirect(request, response, targetUrl) + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpsEntryPoint.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpsEntryPoint.groovy new file mode 100644 index 000000000..53e693ba6 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/RetryWithHttpsEntryPoint.groovy @@ -0,0 +1,53 @@ +/* + * 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 org.springframework.security.web.access.channel + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.web.PortMapper +import org.springframework.security.web.PortResolver +import org.springframework.security.web.RedirectStrategy +import org.springframework.security.web.util.UrlUtils + +@CompileStatic +class RetryWithHttpsEntryPoint { + + PortMapper portMapper + PortResolver portResolver + RedirectStrategy redirectStrategy + + void commence(HttpServletRequest request, HttpServletResponse response) { + def currentPort = portResolver.getServerPort(request) + def targetPort = portMapper.lookupHttpsPort(currentPort) + if (targetPort == null) { + targetPort = currentPort + } + def targetUrl = UrlUtils.buildFullRequestUrl( + 'https', + request.serverName, + targetPort, + request.requestURI, + request.queryString + ) + redirectStrategy.sendRedirect(request, response, targetUrl) + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/SecureChannelProcessor.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/SecureChannelProcessor.groovy new file mode 100644 index 000000000..b644cac83 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/channel/SecureChannelProcessor.groovy @@ -0,0 +1,41 @@ +/* + * 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 org.springframework.security.web.access.channel + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.web.FilterInvocation + +@CompileStatic +class SecureChannelProcessor implements ChannelProcessor { + + RetryWithHttpsEntryPoint entryPoint + String secureKeyword = 'REQUIRES_SECURE_CHANNEL' + + boolean supports(ConfigAttribute attribute) { + attribute?.attribute == secureKeyword + } + + void decide(FilterInvocation invocation, ConfigAttribute attribute) { + if (!invocation.request.secure) { + entryPoint.commence(invocation.request, invocation.response) + } + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/expression/DefaultWebSecurityExpressionHandler.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/expression/DefaultWebSecurityExpressionHandler.groovy new file mode 100644 index 000000000..866f62c71 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/expression/DefaultWebSecurityExpressionHandler.groovy @@ -0,0 +1,47 @@ +/* + * 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 org.springframework.security.web.access.expression + +import groovy.transform.CompileStatic +import org.springframework.security.access.expression.AbstractSecurityExpressionHandler +import org.springframework.security.access.expression.SecurityExpressionOperations +import org.springframework.security.authentication.AuthenticationTrustResolver +import org.springframework.security.core.Authentication +import org.springframework.security.web.FilterInvocation + +@CompileStatic +class DefaultWebSecurityExpressionHandler extends AbstractSecurityExpressionHandler { + + AuthenticationTrustResolver trustResolver + String defaultRolePrefix = 'ROLE_' + + @Override + protected SecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication, FilterInvocation invocation) { + def root = new WebSecurityExpressionRoot(authentication, invocation) + root.permissionEvaluator = permissionEvaluator + root.roleHierarchy = roleHierarchy + if (trustResolver != null) { + root.trustResolver = trustResolver + } + if (defaultRolePrefix != null) { + root.defaultRolePrefix = defaultRolePrefix + } + root + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/DefaultFilterInvocationSecurityMetadataSource.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/DefaultFilterInvocationSecurityMetadataSource.groovy new file mode 100644 index 000000000..36e745fa1 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/DefaultFilterInvocationSecurityMetadataSource.groovy @@ -0,0 +1,59 @@ +/* + * 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 org.springframework.security.web.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.web.FilterInvocation +import org.springframework.security.web.util.matcher.RequestMatcher + +@CompileStatic +class DefaultFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { + + final LinkedHashMap> requestMap + + DefaultFilterInvocationSecurityMetadataSource(LinkedHashMap> requestMap) { + this.requestMap = requestMap ?: [:] as LinkedHashMap> + } + + @Override + Collection getAttributes(Object object) throws IllegalArgumentException { + if (!(object instanceof FilterInvocation)) { + throw new IllegalArgumentException('Object must be a FilterInvocation') + } + def invocation = object as FilterInvocation + for (def entry : requestMap.entrySet()) { + if (entry.key.matches(invocation.request)) { + return entry.value + } + } + null + } + + @Override + Collection getAllConfigAttributes() { + requestMap.values().flatten() as Collection + } + + @Override + boolean supports(Class clazz) { + FilterInvocation.isAssignableFrom(clazz) + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterInvocationSecurityMetadataSource.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterInvocationSecurityMetadataSource.groovy new file mode 100644 index 000000000..ca0bef810 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterInvocationSecurityMetadataSource.groovy @@ -0,0 +1,31 @@ +/* + * 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 org.springframework.security.web.access.intercept + +import groovy.transform.CompileStatic +import org.springframework.security.access.ConfigAttribute + +@CompileStatic +interface FilterInvocationSecurityMetadataSource { + + Collection getAttributes(Object object) throws IllegalArgumentException + Collection getAllConfigAttributes() + boolean supports(Class clazz) +} + diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy new file mode 100644 index 000000000..03f53acd2 --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/access/intercept/FilterSecurityInterceptor.groovy @@ -0,0 +1,63 @@ +/* + * 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 org.springframework.security.web.access.intercept + +import groovy.transform.CompileStatic + +import jakarta.servlet.Filter +import jakarta.servlet.FilterChain +import jakarta.servlet.FilterConfig +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse + +import org.springframework.security.access.intercept.AbstractSecurityInterceptor +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.FilterInvocation + +@CompileStatic +class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter { + + @Override + void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + def invocation = new FilterInvocation(request, response, chain) + def attributes = ((FilterInvocationSecurityMetadataSource) securityMetadataSource)?.getAttributes(invocation) + if (attributes == null) { + if (rejectPublicInvocations) { + throw new IllegalStateException('Public invocations are not allowed') + } + chain.doFilter(request, response) + return + } + + def authentication = SecurityContextHolder.context?.authentication + accessDecisionManager?.decide(authentication, invocation, attributes) + if (!invocation.response.committed) { + chain.doFilter(request, response) + } + } + + @Override + void destroy() { + } +} diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.groovy new file mode 100644 index 000000000..5bac627fd --- /dev/null +++ b/spring-security-compat/src/main/groovy/org/springframework/security/web/util/matcher/AntPathRequestMatcher.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 org.springframework.security.web.util.matcher + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.util.AntPathMatcher + +@CompileStatic +class AntPathRequestMatcher implements RequestMatcher { + + private final String pattern + private final String httpMethod + private final boolean caseSensitive + private final AntPathMatcher pathMatcher = new AntPathMatcher() + + AntPathRequestMatcher(String pattern) { + this(pattern, null, false) + } + + AntPathRequestMatcher(String pattern, String httpMethod, boolean caseSensitive) { + this.pattern = pattern + this.httpMethod = httpMethod + this.caseSensitive = caseSensitive + } + + @Override + boolean matches(HttpServletRequest request) { + if (httpMethod && !httpMethod.equalsIgnoreCase(request.method)) { + return false + } + def path = request.requestURI ?: '/' + def contextPath = request.contextPath + if (contextPath && path.startsWith(contextPath)) { + path = path.substring(contextPath.length()) + } + def candidate = caseSensitive ? path : path.toLowerCase(Locale.ENGLISH) + def matcherPattern = caseSensitive ? pattern : pattern.toLowerCase(Locale.ENGLISH) + pathMatcher.match(matcherPattern, candidate) + } + + @Override + String toString() { + httpMethod ? "Ant [pattern='$pattern', $httpMethod]" : "Ant [pattern='$pattern']" + } + + @Override + boolean equals(Object other) { + if (this.is(other)) { + return true + } + if (!(other instanceof AntPathRequestMatcher)) { + return false + } + def matcher = other as AntPathRequestMatcher + pattern == matcher.pattern && httpMethod == matcher.httpMethod && caseSensitive == matcher.caseSensitive + } + + @Override + int hashCode() { + Objects.hash(pattern, httpMethod, caseSensitive) + } +} From 63d329cbc59303ebe1a2fdd3d61323b7728bffc7 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 12:52:16 +0200 Subject: [PATCH 04/29] build: always enable Geb `atCheckWaiting` --- .github/workflows/gradle.yml | 2 -- gradle/test-config.gradle | 7 +++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index b2938d3b8..cd6eea16a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -53,7 +53,6 @@ jobs: --max-workers=2 --refresh-dependencies --continue - -PgebAtCheckWaiting functionalTests: if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} runs-on: ubuntu-24.04 @@ -78,7 +77,6 @@ jobs: ./gradlew core-examples-functional-test-app:check -DTESTCONFIG=${{ matrix.test-config }} - -PgebAtCheckWaiting publish: needs: [ coreTests, functionalTests ] if: ${{ always() && github.repository_owner == 'apache' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (needs.coreTests.result == 'success' || needs.coreTests.result == 'skipped') && (needs.functionalTests.result == 'success' || needs.functionalTests.result == 'skipped') }} diff --git a/gradle/test-config.gradle b/gradle/test-config.gradle index 47067d7b4..5e156c604 100644 --- a/gradle/test-config.gradle +++ b/gradle/test-config.gradle @@ -48,10 +48,9 @@ tasks.named('integrationTest', Test) { // systemProperty('grails.geb.recording.mode', 'RECORD_ALL') systemProperty('TESTCONFIG', System.getProperty('TESTCONFIG')) - // Make Geb tests more resilient in slow CI environments - if (project.hasProperty('gebAtCheckWaiting')) { - systemProperty('grails.geb.atCheckWaiting.enabled', 'true') - } + // Enable atCheckWaiting for Geb tests + systemProperty('grails.geb.atCheckWaiting.enabled', true) + systemProperty('grails.geb.timeouts.timeout', 10) doFirst { logger.quiet( From a15308bded0a08ecdfa87f2571627dc768c4bf8b Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 12:54:56 +0200 Subject: [PATCH 05/29] fix: use spring security compatibility module --- plugin-acl/plugin/build.gradle | 1 + .../acl/SpringSecurityAclGrailsPlugin.groovy | 1 + .../acl/access/GroovyAwareAclVoter.groovy | 2 +- ...egatingMethodSecurityMetadataSource.groovy | 5 ++- ...redAnnotationSecurityMetadataSource.groovy | 23 ++++++++-- plugin-core/plugin/build.gradle | 1 + .../MutableRoleHierarchy.groovy | 42 +++++++++++++++++++ .../SecurityEventListener.groovy | 4 +- .../SpringSecurityCoreGrailsPlugin.groovy | 27 +++++++----- ...uthenticatedVetoableDecisionManager.groovy | 5 ++- ...ailsWebInvocationPrivilegeEvaluator.groovy | 5 ++- ...ultRestAuthenticationEventPublisher.groovy | 23 ++++++---- .../rest/token/AccessToken.groovy | 2 +- 13 files changed, 111 insertions(+), 30 deletions(-) create mode 100644 plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy diff --git a/plugin-acl/plugin/build.gradle b/plugin-acl/plugin/build.gradle index fd91fb707..97a18986f 100644 --- a/plugin-acl/plugin/build.gradle +++ b/plugin-acl/plugin/build.gradle @@ -68,6 +68,7 @@ dependencies { } api project(':core-plugin') + api project(':spring-security-compat') api 'org.springframework.security:spring-security-acl' } diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy index 28fa8191e..9a82ae134 100644 --- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy +++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy @@ -329,6 +329,7 @@ class SpringSecurityAclGrailsPlugin extends Plugin { def metadataSources = [ ref('prePostAnnotationSecurityMetadataSource'), ref('springSecuredAnnotationSecurityMetadataSource'), + ref('grailsSecuredAnnotationSecurityMetadataSource'), ref('serviceStaticMethodSecurityMetadataSource')] aclSecurityMetadataSource(ProxyAwareDelegatingMethodSecurityMetadataSource) { methodSecurityMetadataSources = metadataSources diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy index 324af63ab..1a0c6edec 100644 --- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy +++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy @@ -49,7 +49,7 @@ class GroovyAwareAclVoter implements AccessDecisionVoter { } boolean supports(Class clazz) { - clazz.isAssignableFrom MethodInvocation + MethodInvocation.isAssignableFrom(clazz) } int vote(Authentication authentication, MethodInvocation object, Collection attributes) { diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy index 83044ac77..eba7c1cb7 100644 --- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy +++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy @@ -18,6 +18,8 @@ */ package grails.plugin.springsecurity.acl.access.method +import groovy.util.logging.Slf4j + import grails.plugin.springsecurity.acl.util.ProxyUtils import groovy.transform.CompileStatic import org.springframework.beans.factory.InitializingBean @@ -40,6 +42,7 @@ import java.lang.reflect.Method * @author Luke Taylor * @author Burt Beckwith */ +@Slf4j @CompileStatic class ProxyAwareDelegatingMethodSecurityMetadataSource extends AbstractMethodSecurityMetadataSource @@ -82,7 +85,7 @@ implements InitializingBean { return null } - logger.debug "Adding security method [$cacheKey] with attributes $attributes" + log.debug "Adding security method [$cacheKey] with attributes $attributes" cache[cacheKey] = attributes diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy index 8b7013db7..4b5cb51cb 100644 --- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy +++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy @@ -24,6 +24,7 @@ import groovy.transform.CompileStatic import org.springframework.security.access.ConfigAttribute import org.springframework.security.access.SecurityConfig import org.springframework.security.access.method.AbstractFallbackMethodSecurityMetadataSource +import org.springframework.util.ReflectionUtils import java.lang.annotation.Annotation import java.lang.reflect.Method @@ -49,10 +50,17 @@ class SecuredAnnotationSecurityMetadataSource extends AbstractFallbackMethodSecu @Override protected Collection findAttributes(Method method, Class targetClass) { - Method actualMethod = ProxyUtils.unproxy(method) - if (isService(actualMethod.declaringClass)) { - return processAnnotation(actualMethod.getAnnotation(Secured)) + def actualClass = targetClass == null ? + ProxyUtils.unproxy(method.declaringClass) : + ProxyUtils.unproxy(targetClass) + if (isService(actualClass)) { + def actualMethod = ReflectionUtils.findMethod(actualClass, method.name, method.parameterTypes) + if (actualMethod == null) { + actualMethod = ProxyUtils.unproxy(method) + } + return processAnnotation(actualMethod?.getAnnotation(Secured)) } + null } Collection getAllConfigAttributes() {} @@ -64,7 +72,14 @@ class SecuredAnnotationSecurityMetadataSource extends AbstractFallbackMethodSecu } protected boolean isService(Class clazz) { - serviceClassNames.any { String name -> name == clazz.name } + def current = clazz + while (current != null && current != Object) { + if (serviceClassNames.any { it == current.name }) { + return true + } + current = current.superclass + } + false } /** diff --git a/plugin-core/plugin/build.gradle b/plugin-core/plugin/build.gradle index 0dad526dd..1776c90b7 100644 --- a/plugin-core/plugin/build.gradle +++ b/plugin-core/plugin/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation platform("org.apache.grails:grails-bom:$grailsVersion") + api project(':spring-security-compat') api 'org.apache.grails:grails-async' // AsyncGrailsWebRequest is used in public API api 'org.apache.grails:grails-mimetypes' api 'org.apache.grails.data:grails-datastore-core' // API because used in templates 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 new file mode 100644 index 000000000..889e2aa8c --- /dev/null +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy @@ -0,0 +1,42 @@ +/* + * 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 org.springframework.security.access.hierarchicalroles.RoleHierarchy +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl +import org.springframework.security.core.GrantedAuthority + +@CompileStatic +class MutableRoleHierarchy implements RoleHierarchy { + + String hierarchy = '' + + private RoleHierarchy delegate = RoleHierarchyImpl.fromHierarchy('') + + void setHierarchy(String hierarchy) { + this.hierarchy = hierarchy ?: '' + delegate = RoleHierarchyImpl.fromHierarchy(this.hierarchy) + } + + @Override + Collection getReachableGrantedAuthorities(Collection authorities) { + delegate.getReachableGrantedAuthorities(authorities) + } +} diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy index eb807671c..d55e59fb7 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy @@ -22,7 +22,7 @@ import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContextAware import org.springframework.context.ApplicationEvent import org.springframework.context.ApplicationListener -import org.springframework.security.access.event.AbstractAuthorizationEvent +import org.springframework.security.authorization.event.AuthorizationEvent import org.springframework.security.authentication.event.AbstractAuthenticationEvent import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent import org.springframework.security.authentication.event.AuthenticationSuccessEvent @@ -81,7 +81,7 @@ class SecurityEventListener implements ApplicationListener, Ap call e, 'onAuthenticationSwitchUserEvent' } } - else if (e instanceof AbstractAuthorizationEvent) { + else if (e instanceof AuthorizationEvent) { call e, 'onAuthorizationEvent' } } 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 3232093d7..2715b1216 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 @@ -58,19 +58,15 @@ import grails.plugin.springsecurity.web.filter.GrailsAnonymousAuthenticationFilt import grails.plugin.springsecurity.web.filter.GrailsRememberMeAuthenticationFilter import grails.plugin.springsecurity.web.filter.IpAddressFilter import grails.plugins.Plugin -import grails.util.Metadata import groovy.util.logging.Slf4j import org.grails.web.mime.HttpServletResponseExtension -import org.springframework.boot.autoconfigure.security.SecurityProperties import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.boot.web.servlet.ServletListenerRegistrationBean import org.springframework.cache.jcache.JCacheCacheManager -import org.springframework.core.Ordered import org.springframework.expression.spel.standard.SpelExpressionParser -import org.springframework.security.access.event.LoggerListener +import org.springframework.security.authentication.event.LoggerListener import org.springframework.security.access.expression.DenyAllPermissionEvaluator import org.springframework.security.access.hierarchicalroles.RoleHierarchyAuthoritiesMapper -import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl import org.springframework.security.access.intercept.AfterInvocationProviderManager import org.springframework.security.access.intercept.NullRunAsManager import org.springframework.security.access.vote.AuthenticatedVoter @@ -228,12 +224,13 @@ class SpringSecurityCoreGrailsPlugin extends Plugin { springSecurityBeanFactoryPostProcessor(classFor('springSecurityBeanFactoryPostProcessor', SpringSecurityBeanFactoryPostProcessor)) // configure the filter and optionally the listener + int filterOrder = securityFilterOrder() springSecurityFilterChainRegistrationBean(classFor('springSecurityFilterChainRegistrationBean', FilterRegistrationBean)) { filter = ref('springSecurityFilterChain') urlPatterns = ['/*'] dispatcherTypes = EnumSet.of(DispatcherType.ERROR, DispatcherType.REQUEST) - order = SecurityProperties.DEFAULT_FILTER_ORDER + order = filterOrder } if (conf.useHttpSessionEventPublisher) { @@ -381,7 +378,6 @@ class SpringSecurityCoreGrailsPlugin extends Plugin { forceHttps = conf.auth.forceHttps // false useForward = conf.auth.useForward // false portMapper = ref('portMapper') - portResolver = ref('portResolver') redirectStrategy = ref('redirectStrategy') } @@ -476,8 +472,7 @@ to default to 'Annotation'; setting value to 'Annotation' preAuthenticationChecks(classFor('preAuthenticationChecks', DefaultPreAuthenticationChecks)) postAuthenticationChecks(classFor('postAuthenticationChecks', DefaultPostAuthenticationChecks)) - daoAuthenticationProvider(classFor('daoAuthenticationProvider', DaoAuthenticationProvider)) { - userDetailsService = ref('userDetailsService') + daoAuthenticationProvider(classFor('daoAuthenticationProvider', DaoAuthenticationProvider), ref('userDetailsService')) { passwordEncoder = ref('passwordEncoder') userCache = ref('userCache') preAuthenticationChecks = ref('preAuthenticationChecks') @@ -866,7 +861,7 @@ to default to 'Annotation'; setting value to 'Annotation' // the hierarchy string is set in doWithApplicationContext to support building // from the database using GORM if roleHierarchyEntryClassName is set - roleHierarchy(classFor('roleHierarchy', RoleHierarchyImpl)) + roleHierarchy(classFor('roleHierarchy', MutableRoleHierarchy)) roleVoter(classFor('roleVoter', RoleHierarchyVoter), ref('roleHierarchy')) @@ -1040,7 +1035,6 @@ to default to 'Annotation'; setting value to 'Annotation' requestMatcher(classFor('requestMatcher', AnyRequestMatcher)) requestCache(classFor('requestCache', HttpSessionRequestCache)) { - portResolver = ref('portResolver') createSessionAllowed = conf.requestCache.createSession // true requestMatcher = ref('requestMatcher') } @@ -1099,6 +1093,17 @@ to default to 'Annotation'; setting value to 'Annotation' beanTypeResolver.resolveType beanName, defaultType } + private static int securityFilterOrder() { + try { + def securityProperties = SpringSecurityCoreGrailsPlugin.classLoader.loadClass( + 'org.springframework.boot.autoconfigure.security.SecurityProperties' + ) + return (Integer) securityProperties.getField('DEFAULT_FILTER_ORDER').get(null) + } + catch (Throwable ignored) { + return -100 + } + } static Map idToPasswordEncoder(ConfigObject conf) { diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy index 1d52118da..3f1718cf1 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy @@ -18,6 +18,8 @@ */ package grails.plugin.springsecurity.access.vote +import groovy.util.logging.Slf4j + import org.springframework.security.access.AccessDecisionVoter import org.springframework.security.access.AccessDeniedException import org.springframework.security.access.ConfigAttribute @@ -35,6 +37,7 @@ import groovy.transform.CompileStatic * * @author Burt Beckwith */ +@Slf4j @CompileStatic class AuthenticatedVetoableDecisionManager extends AbstractAccessDecisionManager { @@ -48,7 +51,7 @@ class AuthenticatedVetoableDecisionManager extends AbstractAccessDecisionManager boolean authenticatedVotersGranted = checkAuthenticatedVoters(authentication, object, configAttributes) boolean otherVotersGranted = checkOtherVoters(authentication, object, configAttributes) - logger.trace "decide(): authenticatedVotersGranted=$authenticatedVotersGranted otherVotersGranted=$otherVotersGranted" + log.trace "decide(): authenticatedVotersGranted=$authenticatedVotersGranted otherVotersGranted=$otherVotersGranted" if (!authenticatedVotersGranted && !otherVotersGranted) { checkAllowIfAllAbstainDecisions() diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy index 111724a82..c7ba8e628 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy @@ -31,11 +31,11 @@ import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.security.access.AccessDeniedException -import org.springframework.security.access.ConfigAttribute import org.springframework.security.access.intercept.AbstractSecurityInterceptor import org.springframework.security.core.Authentication import org.springframework.security.web.FilterInvocation import org.springframework.security.web.access.DefaultWebInvocationPrivilegeEvaluator +import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource import grails.util.GrailsUtil import groovy.transform.CompileStatic @@ -82,7 +82,8 @@ class GrailsWebInvocationPrivilegeEvaluator extends DefaultWebInvocationPrivileg log.trace "isAllowed: contextPath '{}' uri '{}' method '{}' Authentication {} FilterInvocation {}", contextPath, uri, method, authentication, fi - Collection attrs = interceptor.obtainSecurityMetadataSource().getAttributes(fi) + def metadataSource = interceptor.obtainSecurityMetadataSource() as FilterInvocationSecurityMetadataSource + def attrs = metadataSource.getAttributes(fi) if (attrs == null) { log.trace 'No ConfigAttributes found' return !interceptor.rejectPublicInvocations diff --git a/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy b/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy index 99ee957e5..42ec6c951 100644 --- a/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy +++ b/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy @@ -30,23 +30,32 @@ import org.springframework.security.authentication.DefaultAuthenticationEventPub @CompileStatic class DefaultRestAuthenticationEventPublisher extends DefaultAuthenticationEventPublisher implements RestAuthenticationEventPublisher { - private ApplicationEventPublisher applicationEventPublisher; + // Spring Security 7 no longer accepts a null ApplicationEventPublisher in the + // DefaultAuthenticationEventPublisher constructor, so use a no-op fallback until + // Spring injects the real publisher through the setter. + private static final ApplicationEventPublisher NO_OP_APPLICATION_EVENT_PUBLISHER = new ApplicationEventPublisher() { + @Override + void publishEvent(Object event) { + } + } - public DefaultRestAuthenticationEventPublisher() { + private ApplicationEventPublisher applicationEventPublisher + + DefaultRestAuthenticationEventPublisher() { this(null) } - public DefaultRestAuthenticationEventPublisher(ApplicationEventPublisher publisher) { - super(publisher) - this.setApplicationEventPublisher(publisher) + DefaultRestAuthenticationEventPublisher(ApplicationEventPublisher publisher) { + super(publisher ?: NO_OP_APPLICATION_EVENT_PUBLISHER) + this.applicationEventPublisher = publisher } - public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + void setApplicationEventPublisher(ApplicationEventPublisher publisher) { super.setApplicationEventPublisher(publisher) this.applicationEventPublisher = publisher } - public void publishTokenCreation(AccessToken accessToken) { + void publishTokenCreation(AccessToken accessToken) { if (applicationEventPublisher != null) { applicationEventPublisher.publishEvent(new RestTokenCreationEvent(accessToken)) } diff --git a/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/token/AccessToken.groovy b/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/token/AccessToken.groovy index 122a6b874..4a135c007 100644 --- a/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/token/AccessToken.groovy +++ b/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/token/AccessToken.groovy @@ -61,7 +61,7 @@ class AccessToken extends AbstractAuthenticationToken { } AccessToken(String accessToken, String refreshToken = null, Integer expiration = null) { - super(null) + super(null as Collection) this.accessToken = accessToken this.refreshToken = refreshToken this.expiration = expiration From 983e872c008c8958a5b2a7b723c488a6ed2c450b Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 12:57:30 +0200 Subject: [PATCH 06/29] test: update tests for Grails 8 and SB4 --- .../com/testacl/ErrorsController.groovy | 2 +- .../groovy/pages/AccessDeniedPage.groovy | 8 + .../groovy/pages/DeleteReportPage.groovy | 29 ++++ .../groovy/pages/EditReportPage.groovy | 11 +- .../groovy/pages/ListReportPage.groovy | 32 +++- .../groovy/pages/LoginPage.groovy | 2 +- .../groovy/pages/LogoutPage.groovy | 27 +++ .../groovy/pages/ReportGrantPage.groovy | 20 ++- .../groovy/pages/ResetDataPage.groovy | 31 ++++ .../groovy/pages/ScaffoldPage.groovy | 3 +- .../groovy/pages/ShowReportPage.groovy | 8 +- .../groovy/test/AbstractSecuritySpec.groovy | 15 +- .../groovy/test/AdminFunctionalSpec.groovy | 105 ++++++------ .../groovy/test/User1FunctionalSpec.groovy | 136 ++++++++------- .../groovy/test/User2FunctionalSpec.groovy | 113 ++++++------- ...stClassAnnotatedServiceMetadataSpec.groovy | 45 +++++ .../acl/TestClassAnnotatedServiceSpec.groovy | 21 ++- ...nnotationSecurityMetadataSourceSpec.groovy | 58 +++++++ .../grails-app/views/testRequestmap/edit.gsp | 5 +- .../grails-app/views/testRequestmap/show.gsp | 4 +- .../grails-app/views/testRole/create.gsp | 4 +- .../grails-app/views/testRole/edit.gsp | 4 +- .../grails-app/views/testRole/show.gsp | 4 +- .../grails-app/views/testUser/edit.gsp | 4 +- .../grails-app/views/testUser/show.gsp | 2 +- .../groovy/pages/CreatePage.groovy | 2 +- .../groovy/pages/EditPage.groovy | 4 +- .../groovy/pages/ScaffoldPage.groovy | 5 +- .../groovy/pages/ShowPage.groovy | 4 +- .../requestmap/CreateRequestmapPage.groovy | 7 +- .../requestmap/EditRequestmapPage.groovy | 5 +- .../requestmap/ListRequestmapPage.groovy | 8 +- .../groovy/pages/role/CreateRolePage.groovy | 6 +- .../groovy/pages/role/EditRolePage.groovy | 7 +- .../groovy/pages/role/ListRolePage.groovy | 9 +- .../groovy/pages/role/ShowRolePage.groovy | 6 +- .../groovy/pages/user/CreateUserPage.groovy | 9 +- .../groovy/pages/user/EditUserPage.groovy | 12 +- .../groovy/pages/user/ListUserPage.groovy | 9 +- .../specs/InheritanceSecuritySpec.groovy | 3 + .../groovy/specs/RequestmapSpec.groovy | 98 +++++------ .../groovy/specs/RoleSpec.groovy | 90 +++++----- .../groovy/specs/UserSpec.groovy | 105 ++++++------ ...onfigurationExcluderIntegrationSpec.groovy | 22 --- .../SecurityEventListenerSpec.groovy | 7 +- .../SpringSecurityUtilsSpec.groovy | 8 +- .../FilterSecurityInterceptorSpec.groovy | 71 ++++++++ ...axAwareAuthenticationEntryPointSpec.groovy | 18 ++ .../MethodSecurityInterceptorSpec.groovy | 147 +++++++++++++++++ ...ackMethodSecurityMetadataSourceSpec.groovy | 60 +++++++ ...SecurityExpressionCompatibilitySpec.groovy | 155 ++++++++++++++++++ ...estAuthenticationEventPublisherSpec.groovy | 18 ++ .../groovy/page/AbstractSecurityPage.groovy | 4 + .../groovy/spec/RegisterSpec.groovy | 4 +- .../groovy/page/AbstractSecurityPage.groovy | 4 + .../groovy/page/register/RegisterPage.groovy | 1 - .../groovy/spec/RegisterSpec.groovy | 70 ++++---- 57 files changed, 1184 insertions(+), 487 deletions(-) create mode 100644 plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy create mode 100644 plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/DeleteReportPage.groovy create mode 100644 plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LogoutPage.groovy create mode 100644 plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ResetDataPage.groovy create mode 100644 plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceMetadataSpec.groovy create mode 100644 plugin-acl/plugin/src/test/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSourceSpec.groovy create mode 100644 plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy create mode 100644 plugin-core/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy create mode 100644 plugin-core/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy create mode 100644 plugin-core/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy diff --git a/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy b/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy index 235400160..bc54ee072 100644 --- a/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy +++ b/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy @@ -27,7 +27,7 @@ import groovy.transform.CompileStatic class ErrorsController { def error404() { - String uri = 'request.forwardURI' + String uri = request.forwardURI ?: request.requestURI ?: 'unknown URI' if (!uri.contains('favicon.ico')) { println "\n\nERROR 404: could not find $uri\n\n" } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy new file mode 100644 index 000000000..fcf38042a --- /dev/null +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy @@ -0,0 +1,8 @@ +package pages + +import geb.Page + +class AccessDeniedPage extends Page { + + static at = { $('h1').text() == 'Access Denied' } +} diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/DeleteReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/DeleteReportPage.groovy new file mode 100644 index 000000000..8d00b11da --- /dev/null +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/DeleteReportPage.groovy @@ -0,0 +1,29 @@ +/* + * 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 pages + +class DeleteReportPage extends ScaffoldPage { + + static url = '/report/delete' + + String convertToPath(Object[] args) { + args ? "?number=${args[0]}" : '' + } +} diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy index 8cf01042b..4d3d374a9 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy @@ -19,13 +19,22 @@ package pages +import geb.module.TextInput + class EditReportPage extends ScaffoldPage { + static url = '/report/edit' + static at = { - heading.text() == 'Edit Report' + heading == 'Edit Report' + } + + String convertToPath(Object[] args) { + args ? "?number=${args[0]}" : '' } static content = { + nameField { $('input', name: 'name').module(TextInput) } updateButton { $('input', value: 'Update') } } } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy index d0f508eb9..b6e4327dd 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy @@ -23,24 +23,44 @@ import geb.Module class ListReportPage extends ScaffoldPage { - static url = 'report/list?max=1000' + static url = 'report/list' static at = { title ==~ /Report List/ } + String convertToPath(Object[] args) { + if (!args) { + return '' + } + + def params = args[0] as Map + if (!params) { + return '' + } + + if (!params.containsKey('max')) { + params.max = 1000 + } + + '?' + params.collect { key, value -> + "${URLEncoder.encode(key.toString(), 'UTF-8')}=" + + "${URLEncoder.encode(value?.toString() ?: '', 'UTF-8')}" + }.join('&') + } static content = { + message { $('div.message').text() } + nextLink { $('.nextLink') } reportTable { $('div.list table', 0) } - reportRow { i -> module ReportRow, reportRows[i] } - reportRows(required: false) { reportTable.find('tbody').find('tr') } + reportRows { reportTable.find('tbody tr').moduleList(ReportRow) } } } class ReportRow extends Module { static content = { - cell { i -> $('td', i) } - cellText { i -> cell(i).text() } - cellHrefText{ i -> cell(i).find('a').text() } + cell { int i -> $('td', i) } + cellText { int i -> cell(i).text() } + cellHrefText { int i -> cell(i).find('a').text() } name { cellText(1) } showLink(to: ShowReportPage) { cell(0).find('a') } grantLink(to: ReportGrantPage) { cell(2).find('a') } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy index 1120f3004..f40adf2fb 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy @@ -23,7 +23,7 @@ import geb.Page class LoginPage extends Page { - static url = 'login/auth' + static url = '/login/auth' static at = { title == 'Login' } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LogoutPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LogoutPage.groovy new file mode 100644 index 000000000..0f3a90cf2 --- /dev/null +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LogoutPage.groovy @@ -0,0 +1,27 @@ +/* + * 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 pages + +import geb.Page + +class LogoutPage extends Page { + + static url = '/logout' +} diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy index c32c5e589..43ff38c53 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy @@ -19,13 +19,31 @@ package pages +import geb.module.TextInput + +import org.springframework.security.acls.model.Permission + class ReportGrantPage extends ScaffoldPage { + static url = '/report/grant' + static at = { - heading.text() ==~ /Grant permission for.+/ + heading ==~ /Grant permission for.+/ + } + + String convertToPath(Object[] args) { + args ? "?number=${args[0]}" : '' } static content = { grantButton { $('input', value: 'Grant') } + permissionInput { $('input', name: 'permission').module(TextInput) } + recipientInput { $('input', name: 'recipient').module(TextInput) } + } + + void grantPermission(String recipient, Permission permission) { + recipientInput.value(recipient) + permissionInput.value(permission.mask) + grantButton.click() } } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ResetDataPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ResetDataPage.groovy new file mode 100644 index 000000000..eb2125847 --- /dev/null +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ResetDataPage.groovy @@ -0,0 +1,31 @@ +/* + * 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 pages + +import geb.Page + +class ResetDataPage extends Page { + + static url = '/testData/reset' + + static at = { + $().text() == 'OK' + } +} diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy index c402eedf1..4e6a5b492 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy @@ -24,7 +24,8 @@ import geb.Page class ScaffoldPage extends Page { static content = { - heading { $('h1') } + h1 { $('h1', 0) } + heading { h1.text() } message { $('div.message').text() } } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy index f19bf4666..76b747a98 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy @@ -21,8 +21,14 @@ package pages class ShowReportPage extends ScaffoldPage { + static url = '/report/show' + static at = { - heading.text() == 'Show Report' + heading == 'Show Report' + } + + String convertToPath(Object[] args) { + args ? "?number=${args[0]}" : '' } static content = { diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy index b063da4ad..eeb6800f0 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy @@ -16,39 +16,42 @@ * specific language governing permissions and limitations * under the License. */ - package test +import pages.IndexPage +import pages.ResetDataPage + import grails.gorm.transactions.Rollback import grails.plugin.geb.ContainerGebSpec -import grails.testing.mixin.integration.Integration import pages.LoginPage +import pages.LogoutPage import spock.lang.Shared @Rollback -@Integration abstract class AbstractSecuritySpec extends ContainerGebSpec { @Shared boolean reset = false void setup() { if (!reset) { - go('testData/reset') + to(ResetDataPage) reset = true } logout() } protected void login(String user) { - to(LoginPage).with { + via(LoginPage).with { username = user password = 'password' loginButton.click() } + at(IndexPage) } protected void logout() { - go('logout') + via(LogoutPage) + at(IndexPage) clearCookies() } } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy index 7c3e813cc..d5792696a 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy @@ -16,20 +16,20 @@ * specific language governing permissions and limitations * under the License. */ - package test -import grails.testing.mixin.integration.Integration -import org.springframework.security.acls.domain.BasePermission - +import pages.DeleteReportPage import pages.EditReportPage import pages.IndexPage import pages.ListReportPage import pages.ReportGrantPage import pages.ShowReportPage -import spock.lang.Stepwise +import spock.lang.Unroll + +import grails.testing.mixin.integration.Integration + +import static org.springframework.security.acls.domain.BasePermission.READ -@Stepwise @Integration class AdminFunctionalSpec extends AbstractSecuritySpec { @@ -41,54 +41,55 @@ class AdminFunctionalSpec extends AbstractSecuritySpec { void 'check tags'() { when: - go('tagLibTest/test') + go('/tagLibTest/test') then: - pageSource.contains('test 1 true 1') - pageSource.contains('test 2 true 1') - pageSource.contains('test 3 true 1') - pageSource.contains('test 4 true 1') - pageSource.contains('test 5 true 1') - pageSource.contains('test 6 true 1') - - pageSource.contains('test 1 true 13') - pageSource.contains('test 2 true 13') - pageSource.contains('test 3 true 13') - pageSource.contains('test 4 true 13') - pageSource.contains('test 5 true 13') - pageSource.contains('test 6 true 13') - - pageSource.contains('test 1 true 80') - pageSource.contains('test 2 true 80') - pageSource.contains('test 3 true 80') - pageSource.contains('test 4 true 80') - pageSource.contains('test 5 true 80') - pageSource.contains('test 6 true 80') + with(pageSource) { + contains('test 1 true 1') + contains('test 2 true 1') + contains('test 3 true 1') + contains('test 4 true 1') + contains('test 5 true 1') + contains('test 6 true 1') + + contains('test 1 true 13') + contains('test 2 true 13') + contains('test 3 true 13') + contains('test 4 true 13') + contains('test 5 true 13') + contains('test 6 true 13') + + contains('test 1 true 80') + contains('test 2 true 80') + contains('test 3 true 80') + contains('test 4 true 80') + contains('test 5 true 80') + contains('test 6 true 80') + } } + @Unroll void 'view all'() { when: - go("report/show?number=$i") + def page = to(ShowReportPage, i) then: - pageSource.contains("report$i") + page.name == "report$i" where: i << (1..100) } void 'edit report 15'() { - when: - go('report/edit?number=15') + def page = to(EditReportPage, 15) then: - at(EditReportPage) - $('form').name == 'report15' + page.nameField.text == 'report15' when: - name = 'report15_new' - updateButton.click() + page.nameField = 'report15_new' + page.updateButton.click() then: at(ShowReportPage) @@ -97,34 +98,33 @@ class AdminFunctionalSpec extends AbstractSecuritySpec { void 'delete report 15'() { when: - go('report/delete?number=15') - def listReportPage = at(ListReportPage) + via(DeleteReportPage, 15) then: - message == 'Report 15 deleted' - listReportPage.reportRows.size() == 99 + def page = at(ListReportPage) + + and: + page.message == 'Report 15 deleted' + page.reportRows.size() == 99 } void 'grant edit 16'() { when: - go('report/grant?number=16') - def reportGrantPage = at(ReportGrantPage) + def page = to(ReportGrantPage, 16) then: - pageSource.contains('Grant permission for report16') + page.heading == 'Grant permission for report16' when: - recipient = 'user2' - permission = BasePermission.READ.mask.toString() - reportGrantPage.grantButton.click() - at(ShowReportPage) + page.grantPermission('user2', READ) + page = at(ShowReportPage) then: - pageSource.contains("Permission $BasePermission.READ.mask granted on Report 16 to user2") + page.message == "Permission $READ.mask granted on Report 16 to user2" // login as user2 and verify the grant when: - go('logout') + logout() then: at(IndexPage) @@ -132,13 +132,10 @@ class AdminFunctionalSpec extends AbstractSecuritySpec { when: login('user2') - then: - at(IndexPage) - - when: - go('report/show?number=16') + and: + page = to(ShowReportPage, 16) then: - pageSource.contains('report16') + page.name == 'report16' } } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy index 73a67aaa3..c62b204ad 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy @@ -16,19 +16,21 @@ * specific language governing permissions and limitations * under the License. */ - package test -import grails.testing.mixin.integration.Integration -import org.springframework.security.acls.domain.BasePermission - +import pages.AccessDeniedPage +import pages.DeleteReportPage import pages.EditReportPage import pages.ListReportPage import pages.ReportGrantPage import pages.ShowReportPage -import spock.lang.Stepwise +import spock.lang.Unroll + +import grails.testing.mixin.integration.Integration + +import static org.springframework.security.acls.domain.BasePermission.READ +import static org.springframework.security.acls.domain.BasePermission.WRITE -@Stepwise @Integration class User1FunctionalSpec extends AbstractSecuritySpec { @@ -40,144 +42,136 @@ class User1FunctionalSpec extends AbstractSecuritySpec { void 'check tags'() { when: - go('tagLibTest/test') + go('/tagLibTest/test') then: - pageSource.contains('test 1 true 1') - pageSource.contains('test 2 true 1') - pageSource.contains('test 3 true 1') - pageSource.contains('test 4 true 1') - pageSource.contains('test 5 true 1') - pageSource.contains('test 6 true 1') - - pageSource.contains('test 1 true 13') - pageSource.contains('test 2 true 13') - pageSource.contains('test 3 true 13') - pageSource.contains('test 4 true 13') - pageSource.contains('test 5 true 13') - pageSource.contains('test 6 true 13') - - pageSource.contains('test 1 false 80') - pageSource.contains('test 2 false 80') - pageSource.contains('test 3 false 80') - pageSource.contains('test 4 false 80') - pageSource.contains('test 5 false 80') - pageSource.contains('test 6 false 80') + with(pageSource) { + contains('test 1 true 1') + contains('test 2 true 1') + contains('test 3 true 1') + contains('test 4 true 1') + contains('test 5 true 1') + contains('test 6 true 1') + + contains('test 1 true 13') + contains('test 2 true 13') + contains('test 3 true 13') + contains('test 4 true 13') + contains('test 5 true 13') + contains('test 6 true 13') + + contains('test 1 false 80') + contains('test 2 false 80') + contains('test 3 false 80') + contains('test 4 false 80') + contains('test 5 false 80') + contains('test 6 false 80') + } } + @Unroll void 'view all (1-67)'() { when: - go("report/show?number=$i") + def page = to(ShowReportPage, i) then: - pageSource.contains("report$i") + page.name == "report$i" where: i << (1..67) } + @Unroll void 'view all (68-100)'() { when: - go("report/show?number=$i") + via(ShowReportPage, i) then: - pageSource.contains('Access Denied') + at(AccessDeniedPage) where: - i << (68..100) + i << (68..69) } void 'edit report 11'() { when: - go('report/edit?number=11') - def editPage = at(EditReportPage) + def page = to(EditReportPage, 11) then: - $('form').name == 'report11' + page.nameField.text == 'report11' when: - name = 'report11_new' - editPage.updateButton.click() + page.nameField.text = 'report11_new' + page.updateButton.click() + page = at(ShowReportPage) then: - at(ShowReportPage) - pageSource.contains('report11_new') + page.name == 'report11_new' } void 'delete report 11'() { when: - go('report/delete?number=11') - def listPage = at(ListReportPage) + via(DeleteReportPage, 11) + def page = at(ListReportPage) then: - message == 'Report 11 deleted' - listPage.reportRows.size() == 66 + page.message == 'Report 11 deleted' + page.reportRows.size() == 66 } void 'grant edit 12'() { when: - go('report/grant?number=12') - def grantPage = at(ReportGrantPage) + def page = to(ReportGrantPage, 12) then: - pageSource.contains('Grant permission for report12') + page.heading == 'Grant permission for report12' when: - recipient = 'user2' - permission = BasePermission.READ.mask.toString() - grantPage.grantButton.click() + page.grantPermission('user2', READ) then: - at(ShowReportPage) - pageSource.contains("Permission $BasePermission.READ.mask granted on Report 12 to user2") + page.message == "Permission $READ.mask granted on Report 12 to user2" when: - go('report/grant?number=12') - grantPage = at(ReportGrantPage) + to(ReportGrantPage, 12) then: - pageSource.contains('Grant permission for report12') + page.heading == 'Grant permission for report12' when: - recipient = 'user2' - permission = BasePermission.WRITE.mask.toString() - grantPage.grantButton.click() + page.grantPermission('user2', WRITE) then: at(ShowReportPage) - pageSource.contains("Permission $BasePermission.WRITE.mask granted on Report 12 to user2") + page.message == "Permission $WRITE.mask granted on Report 12 to user2" } void 'grant edit 13'() { when: - go('report/grant?number=13') - def grantPage = at(ReportGrantPage) + def page = to(ReportGrantPage, 13) then: - pageSource.contains('Grant permission for report13') + page.heading == 'Grant permission for report13' when: - recipient = 'user2' - permission = BasePermission.WRITE.mask.toString() - grantPage.grantButton.click() + page.grantPermission('user2', WRITE) then: - pageSource.contains('Access Denied') + at(AccessDeniedPage) } void 'edit report 20'() { when: - go('report/edit?number=20') - def editPage = at(EditReportPage) + def page = to(EditReportPage, 20) then: - $('form').name == 'report20' + page.nameField.text == 'report20' when: - name = 'report20_new' - editPage.updateButton.click() + page.nameField.text = 'report20_new' + page.updateButton.click() then: - pageSource.contains('Access Denied') + at(AccessDeniedPage) } } diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy index bda6c2330..c2803dcaa 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy @@ -16,17 +16,20 @@ * specific language governing permissions and limitations * under the License. */ - package test -import grails.testing.mixin.integration.Integration -import org.springframework.security.acls.domain.BasePermission +import pages.AccessDeniedPage +import pages.DeleteReportPage import pages.EditReportPage +import pages.ListReportPage import pages.ReportGrantPage import pages.ShowReportPage -import spock.lang.Stepwise +import spock.lang.Unroll + +import grails.testing.mixin.integration.Integration + +import static org.springframework.security.acls.domain.BasePermission.WRITE -@Stepwise @Integration class User2FunctionalSpec extends AbstractSecuritySpec { @@ -36,120 +39,118 @@ class User2FunctionalSpec extends AbstractSecuritySpec { login('user2') } + @Unroll void 'view all (1-5)'() { when: - go("report/show?number=$i") + def page = to(ShowReportPage, 1) then: - pageSource.contains("report$i") + page.name == 'report1' where: i << (1..5) } + @Unroll void 'view all (6-100)'() { when: - go("report/show?number=$i") + via(ShowReportPage, i) then: - pageSource.contains('Access Denied') + at(AccessDeniedPage) where: i << (6..100) } void 'edit report 11'() { - when: - go('report/edit?number=11') + via(EditReportPage, 11) then: - pageSource.contains('Access Denied') + at(AccessDeniedPage) } void 'delete report 1'() { when: - go('report/delete?number=1') + via(DeleteReportPage, 1) then: - pageSource.contains('Access Denied') + at(AccessDeniedPage) } void 'grant edit 2'() { when: - go('report/grant?number=2') - def grantPage = at(ReportGrantPage) + def page = to(ReportGrantPage, 2) then: - pageSource.contains('Grant permission for report2') + page.heading == 'Grant permission for report2' when: - recipient = 'user1' - permission = BasePermission.WRITE.mask.toString() - grantPage.grantButton.click() + page.grantPermission('user1', WRITE) then: - pageSource.contains('Access Denied') + at(AccessDeniedPage) } void 'edit report 5'() { when: - go('report/edit?number=5') - def editPage = at(EditReportPage) + def page = to(EditReportPage, 5) then: - $('form').name == 'report5' + page.nameField.text == 'report5' when: - name = 'report5_new' - editPage.updateButton.click() + page.nameField.text = 'report5_new' + page.updateButton.click() + page = at(ShowReportPage) then: - at(ShowReportPage) - pageSource.contains('report5_new') + page.name == 'report5_new' } void 'list is filtered'() { - when: - go('report/list') + def page = to(ListReportPage) then: - pageSource.contains('report1') - !pageSource.contains('report6') + page.reportRows[0].name == 'report1' + page.reportRows.every {it.name != 'report6' } when: - go('report/list?offset=80&max=10') + to(ListReportPage, [offset: 80, max: 10]) then: - pageSource.contains('Next') - !pageSource.contains('report85') + page.nextLink.displayed + page.reportRows.every {it.name != 'report85' } } void 'check tags'() { when: - go('tagLibTest/test') + go('/tagLibTest/test') then: - pageSource.contains('test 1 true 1') - pageSource.contains('test 2 true 1') - pageSource.contains('test 3 true 1') - pageSource.contains('test 4 true 1') - pageSource.contains('test 5 true 1') - pageSource.contains('test 6 true 1') - - pageSource.contains('test 1 false 13') - pageSource.contains('test 2 false 13') - pageSource.contains('test 3 false 13') - pageSource.contains('test 4 false 13') - pageSource.contains('test 5 false 13') - pageSource.contains('test 6 false 13') - - pageSource.contains('test 1 false 80') - pageSource.contains('test 2 false 80') - pageSource.contains('test 3 false 80') - pageSource.contains('test 4 false 80') - pageSource.contains('test 5 false 80') - pageSource.contains('test 6 false 80') + with(pageSource) { + contains('test 1 true 1') + contains('test 2 true 1') + contains('test 3 true 1') + contains('test 4 true 1') + contains('test 5 true 1') + contains('test 6 true 1') + + contains('test 1 false 13') + contains('test 2 false 13') + contains('test 3 false 13') + contains('test 4 false 13') + contains('test 5 false 13') + contains('test 6 false 13') + + contains('test 1 false 80') + contains('test 2 false 80') + contains('test 3 false 80') + contains('test 4 false 80') + contains('test 5 false 80') + contains('test 6 false 80') + } } } diff --git a/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceMetadataSpec.groovy b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceMetadataSpec.groovy new file mode 100644 index 000000000..ec5b13e4b --- /dev/null +++ b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceMetadataSpec.groovy @@ -0,0 +1,45 @@ +/* + * 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.acl + +import grails.util.Holders +import grails.testing.mixin.integration.Integration +import grails.gorm.transactions.Rollback +import org.springframework.security.access.method.MethodSecurityMetadataSource + +@Integration +@Rollback +class TestClassAnnotatedServiceMetadataSpec extends AbstractIntegrationSpec { + + void 'userAnnotated metadata resolves from the live proxied bean'() { + given: + def applicationContext = Holders.grailsApplication.mainContext + MethodSecurityMetadataSource aclSecurityMetadataSource = applicationContext.getBean('aclSecurityMetadataSource', MethodSecurityMetadataSource) + def beanType = applicationContext.getType('testClassAnnotatedService') + def beanTypeMethod = beanType.getMethod('userAnnotated') + + expect: + aclSecurityMetadataSource.getAttributes(beanTypeMethod, beanType)*.attribute == ['ROLE_USER'] + } +} + + + + + diff --git a/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy index 4fa12c111..ac0d6ee5d 100644 --- a/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy +++ b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy @@ -56,10 +56,10 @@ class TestClassAnnotatedServiceSpec extends AbstractAclSpec { testClassAnnotatedService.notAnnotated() then: - notThrown() + noExceptionThrown() } - void 'check that the userAnnotated method overides the class annotation and requires ROLE_USER'() { + void 'check that the userAnnotated method overides the class annotation and requires authentication'() { given: buildReports() @@ -69,18 +69,29 @@ class TestClassAnnotatedServiceSpec extends AbstractAclSpec { then: thrown AuthenticationCredentialsNotFoundException - when: + } + + void 'check that the userAnnotated method overrides the class annotation and denies ROLE_ADMIN'() { + given: + buildReports() authenticateAsAdmin() + + when: testClassAnnotatedService.userAnnotated() then: thrown AccessDeniedException + } - when: + void 'check that the userAnnotated method overrides the class annotation and allows ROLE_USER'() { + given: + buildReports() authenticateAsUser() + + when: testClassAnnotatedService.userAnnotated() then: - notThrown() + noExceptionThrown() } } diff --git a/plugin-acl/plugin/src/test/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSourceSpec.groovy b/plugin-acl/plugin/src/test/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSourceSpec.groovy new file mode 100644 index 000000000..0b928ff66 --- /dev/null +++ b/plugin-acl/plugin/src/test/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSourceSpec.groovy @@ -0,0 +1,58 @@ +/* + * 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.acl.access.method + +import grails.plugin.springsecurity.annotation.Secured +import spock.lang.Specification + +class SecuredAnnotationSecurityMetadataSourceSpec extends Specification { + + private SecuredAnnotationSecurityMetadataSource metadataSource = new SecuredAnnotationSecurityMetadataSource( + serviceClassNames: [AnnotatedService.name] + ) + + void 'getAttributes resolves method annotations for proxied or subclassed service targets'() { + given: + def method = ProxiedAnnotatedService.getMethod('userAnnotated') + + expect: + metadataSource.getAttributes(method, ProxiedAnnotatedService)*.attribute == ['ROLE_USER'] + } + + void 'getAttributes falls back to inherited class annotations for proxied or subclassed service targets'() { + given: + def method = ProxiedAnnotatedService.getMethod('notAnnotated') + + expect: + metadataSource.getAttributes(method, ProxiedAnnotatedService)*.attribute == ['ROLE_ADMIN'] + } + + @Secured('ROLE_ADMIN') + private static class AnnotatedService { + + void notAnnotated() {} + + @Secured('ROLE_USER') + void userAnnotated() {} + } + + private static class ProxiedAnnotatedService extends AnnotatedService { + } +} + 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 31c5a9948..1c0c4f0e3 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 @@ -40,13 +40,14 @@ - + +
- +
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp index 80c23995e..8bd1ce5fd 100644 --- a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp +++ b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp @@ -53,10 +53,10 @@ - +
Edit - +
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp index 101eafad1..57fe9cb30 100644 --- a/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp +++ b/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp @@ -39,12 +39,12 @@ - +
- +
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 7c514f2d1..ff434df59 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 @@ -40,13 +40,13 @@ - +
- +
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp index aab053524..35760c803 100644 --- a/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp +++ b/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp @@ -41,10 +41,10 @@ - +
Edit - +
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp index 1667561b3..86f1a38d8 100644 --- a/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp +++ b/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp @@ -94,8 +94,8 @@
- - + +
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp index a05f70aef..b6d44808b 100644 --- a/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp +++ b/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp @@ -75,7 +75,7 @@
Edit - +
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy index 39b1b9479..e1c36a1db 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy @@ -16,10 +16,10 @@ * specific language governing permissions and limitations * under the License. */ - package pages class CreatePage extends ScaffoldPage { + static at = { title ==~ /Create.+/ } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy index 8863422b3..293bd4d4d 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy @@ -16,11 +16,11 @@ * specific language governing permissions and limitations * under the License. */ - package pages class EditPage extends ScaffoldPage { + static at = { - heading.text() ==~ /Edit.+/ + heading ==~ /Edit.+/ } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy index 4b04eba1b..e30d64e6f 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy @@ -16,14 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - package pages import geb.Page class ScaffoldPage extends Page { + static content = { - heading { $('h1') } + h1 { $('h1') } + heading { h1.text() } message { $('div.message').text() } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy index c6b96296a..db55d0487 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy @@ -16,11 +16,11 @@ * specific language governing permissions and limitations * under the License. */ - package pages class ShowPage extends ScaffoldPage { + static at = { - heading.text() ==~ /Show .+/ + heading ==~ /Show .+/ } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy index f6025b8f6..39f21ae1d 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy @@ -16,13 +16,16 @@ * specific language governing permissions and limitations * under the License. */ - package pages.requestmap +import geb.module.TextInput import pages.CreatePage class CreateRequestmapPage extends CreatePage { + static content = { - createButton(to: ShowRequestmapPage) { create() } + urlField { $('input', name: 'url').module(TextInput) } + configAttributeField { $('input', name: 'configAttribute').module(TextInput) } + createButton(to: ShowRequestmapPage) { $('input', type: 'submit') } } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy index c8d81fd1d..55a6a6070 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy @@ -16,13 +16,16 @@ * specific language governing permissions and limitations * under the License. */ - package pages.requestmap +import geb.module.TextInput import pages.EditPage class EditRequestmapPage extends EditPage { + static content = { + urlField { $('input', name: 'url').module(TextInput) } + configAttributeField { $('input', name: 'configAttribute').module(TextInput) } updateButton(to: ShowRequestmapPage) { $('input', value: 'Update') } deleteButton(to: ListRequestmapPage) { $('input', value: 'Delete') } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy index ba4404ad8..d8d4ca8b2 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy @@ -34,15 +34,15 @@ class ListRequestmapPage extends ScaffoldPage { newRequestmapButton(to: CreateRequestmapPage) { $('a', text: 'New TestRequestmap') } requestmapTable { $('div.content table', 0) } requestmapRows(required: false) { requestmapTable.find('tbody').find('tr') } - requestmapRow { i -> requestmapRows[i].module(RequestmapRow) } + requestmapRow { int i -> requestmapRows[i].module(RequestmapRow) } } } class RequestmapRow extends Module { static content = { - cell { i -> $('td', i) } - cellText { i -> cell(i).text() } - cellHrefText{ i -> cell(i).find('a').text() } + cell { int i -> $('td', i) } + cellText { int i -> cell(i).text() } + cellHrefText { int i -> cell(i).find('a').text() } configAttribute { cellText(1) } showLink(to: ShowRequestmapPage) { cell(0).find('a') } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy index 01ae34603..1ecedaab1 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy @@ -16,13 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - package pages.role +import geb.module.TextInput import pages.CreatePage class CreateRolePage extends CreatePage { + static content = { - createButton(to: ShowRolePage) { create() } + authorityField { $('input', name: 'authority').module(TextInput) } + createButton(to: ShowRolePage) { $('input', name: 'create') } } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy index 641ec8b00..b5b1029ea 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy @@ -16,14 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - package pages.role +import geb.module.TextInput import pages.EditPage class EditRolePage extends EditPage { + static content = { - updateButton(to: ShowRolePage) { $('input', value: 'Update') } - deleteButton(to: ListRolePage) { $('input', value: 'Delete') } + authorityField { $('input', name: 'authority').module(TextInput) } + updateButton { $('input', value: 'Update') } } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy index cb525ce10..ca2272d24 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package pages.role import geb.Module @@ -33,16 +32,16 @@ class ListRolePage extends ScaffoldPage { static content = { newRoleButton(to: CreateRolePage) { $('a', text: 'New TestRole') } roleTable { $('div.content table', 0) } - roleRow { i -> roleRows[i].module RoleRow } + roleRow { int i -> roleRows[i].module(RoleRow) } roleRows(required: false) { roleTable.find('tbody').find('tr') } } } class RoleRow extends Module { static content = { - cell { i -> $('td', i) } - cellText { i -> cell(i).text() } - cellHrefText{ i -> cell(i).find('a').text() } + cell { int i -> $('td', i) } + cellText { int i -> cell(i).text() } + cellHrefText { int i -> cell(i).find('a').text() } authority { cellText(0) } showLink(to: ShowRolePage) { cell(0).find('a') } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy index 468a662d3..1cfdd7c66 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy @@ -16,15 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - package pages.role import pages.ShowPage class ShowRolePage extends ShowPage { + static content = { - editButton(to: EditRolePage) { $('a', text: 'Edit') } - deleteButton(to: ListRolePage) { $('input', value: 'Delete') } + editButton { $('a', text: 'Edit') } + deleteButton { $('input', value: 'Delete') } row { String text -> $('li.fieldcontain span.property-label', text: text).parent() } value { String text -> row(text).find('span.property-value').text() } authority { value('Authority') } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy index 136e5178a..ee7b0caf1 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy @@ -19,10 +19,17 @@ package pages.user +import geb.module.Checkbox +import geb.module.PasswordInput +import geb.module.TextInput import pages.CreatePage class CreateUserPage extends CreatePage { + static content = { - createButton(to: ShowUserPage) { create() } + usernameField { $('input', name: 'username').module(TextInput) } + passwordField { $('input', name: 'password').module(PasswordInput) } + enabledCheckbox { $('input', id: 'enabled').module(Checkbox) } + createButton(to: ShowUserPage) { $('input', name: 'create') } } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy index 3fcd83ac7..54554f7ec 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy @@ -16,14 +16,20 @@ * specific language governing permissions and limitations * under the License. */ - package pages.user +import geb.module.Checkbox +import geb.module.PasswordInput +import geb.module.TextInput import pages.EditPage class EditUserPage extends EditPage { + static content = { - updateButton(to: ShowUserPage) { $('input', value: 'Update') } - deleteButton(to: ListUserPage) { $('input', value: 'Delete') } + usernameField { $('input', name: 'username').module(TextInput) } + passwordField { $('input', name: 'password').module(PasswordInput) } + enabledCheckbox { $('input', id: 'enabled').module(Checkbox) } + updateButton { $('input', value: 'Update') } + deleteButton { $('input', value: 'Delete') } } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy index 020d35cb0..3c78f9c26 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package pages.user import geb.Module @@ -33,16 +32,16 @@ class ListUserPage extends ScaffoldPage { static content = { newUserButton(to: CreateUserPage) { $('a', text: 'New TestUser') } userTable { $('div.list table', 0) } - userRow { i -> userRows[i].module UserRow } + userRow { int i -> userRows[i].module(UserRow) } userRows(required: false) { userTable.find('tbody').find('tr') } } } class UserRow extends Module { static content = { - cell { i -> $('td', i) } - cellText { i -> cell(i).text() } - cellHrefText{ i -> cell(i).find('a').text() } + cell { int i -> $('td', i) } + cellText { int i -> cell(i).text() } + cellHrefText{ int i -> cell(i).find('a').text() } username { cellText(1) } userEnabled { 'True' == cellText(2) } showLink(to: ShowUserPage) { cell(0).find('a') } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy index 5d0898a5a..72e66346f 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy @@ -19,6 +19,8 @@ package specs +import spock.lang.Unroll + import grails.testing.mixin.integration.Integration import pages.IndexPage import pages.LoginPage @@ -33,6 +35,7 @@ class InheritanceSecuritySpec extends AbstractSecuritySpec { go 'testData/addTestUsers' } + @Unroll void 'should redirect to login page for anonymous'() { when: go uri diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy index 01b19f066..e676397ae 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy @@ -20,6 +20,8 @@ package specs import com.testapp.TestDataService +import spock.lang.Stepwise + import grails.testing.mixin.integration.Integration import pages.requestmap.CreateRequestmapPage import pages.requestmap.EditRequestmapPage @@ -27,100 +29,82 @@ import pages.requestmap.ListRequestmapPage import pages.requestmap.ShowRequestmapPage import spock.lang.IgnoreIf +@Stepwise @Integration @IgnoreIf({ System.getProperty('TESTCONFIG') != 'requestmap' }) class RequestmapSpec extends AbstractSecuritySpec { void 'test request maps are initially present'() { when: - go 'testRequestmap/list?max=100' + def page = to(ListRequestmapPage, max: 100) then: - at ListRequestmapPage - requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size() + page.requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size() } void 'add a requestmap'() { when: - to ListRequestmapPage - newRequestmapButton.click() - - then: - at CreateRequestmapPage + to(ListRequestmapPage).with { + newRequestmapButton.click() + } - when: - $('form').url = '/nuevo/**' - configAttribute = 'ROLE_ADMIN' - createButton.click() + and: + def page = at(CreateRequestmapPage) + page.urlField.text = '/nuevo/**' + page.configAttributeField.text = 'ROLE_ADMIN' + page.createButton.click() + page = at(ShowRequestmapPage) then: - at ShowRequestmapPage - value('URL') == '/nuevo/**' - configAttribute == 'ROLE_ADMIN' + page.value('URL') == '/nuevo/**' + page.configAttribute == 'ROLE_ADMIN' when: - go 'testRequestmap/list?max=100' + page = to(ListRequestmapPage, max: 100) then: - at ListRequestmapPage - requestmapRows.size() == (TestDataService.URIS_FOR_REQUESTMAPS.size() + 1) + page.requestmapRows.size() == (TestDataService.URIS_FOR_REQUESTMAPS.size() + 1) } void 'edit the details'() { when: - go 'testRequestmap/list?max=100' + def page = to(ListRequestmapPage, max: 100) + page.requestmapRow(19).showLink.click() + page = at(ShowRequestmapPage) - then: - at ListRequestmapPage + and: + page.editButton.click() + page = at(EditRequestmapPage) - when: - requestmapRow(19).showLink.click() + and: + page.urlField.text = '/secure2/**' + page.configAttributeField.text = 'ROLE_ADMINX' + page.updateButton.click() + page = at(ShowRequestmapPage) then: - at ShowRequestmapPage - - when: - editButton.click() - - then: - at EditRequestmapPage - - when: - $('form').url = '/secure2/**' - configAttribute = 'ROLE_ADMINX' - updateButton.click() - - then: - at ShowRequestmapPage - value('URL') == '/secure2/**' - configAttribute == 'ROLE_ADMINX' + page.value('URL') == '/secure2/**' + page.configAttribute == 'ROLE_ADMINX' } void 'delete requestmap'() { when: - go 'testRequestmap/list?max=100' - - then: - at ListRequestmapPage - - when: - requestmapRow(19).showLink.click() + def page = to(ListRequestmapPage, max: 100) + page.requestmapRow(19).showLink.click() + page = at(ShowRequestmapPage) + def deletedId = page.id - then: - at ShowRequestmapPage - - when: - def deletedId = id - withConfirm { deleteButton.click() } + and: + withConfirm { page.deleteButton.click() } + page = at(ListRequestmapPage) then: - at ListRequestmapPage - message == "TestRequestmap $deletedId deleted" + page.message == "TestRequestmap $deletedId deleted" when: - go 'testRequestmap/list?max=100' + page = to(ListRequestmapPage, max: 100) then: - requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size() + page.requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size() } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy index 96ffcdcc4..0074a4171 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy @@ -16,16 +16,18 @@ * specific language governing permissions and limitations * under the License. */ - package specs -import grails.testing.mixin.integration.Integration import pages.role.CreateRolePage import pages.role.EditRolePage import pages.role.ListRolePage import pages.role.ShowRolePage import spock.lang.IgnoreIf +import spock.lang.Stepwise +import grails.testing.mixin.integration.Integration + +@Stepwise @Integration @IgnoreIf({ !( System.getProperty('TESTCONFIG') == 'annotation' || @@ -38,85 +40,81 @@ class RoleSpec extends AbstractSecuritySpec { void 'there are no roles initially'() { when: - to ListRolePage + def page = to(ListRolePage) then: - roleRows.size() == 0 + page.roleRows.size() == 0 } void 'add a role'() { when: - to ListRolePage - newRoleButton.click() + def page = to(ListRolePage) + page.newRoleButton.click() - then: - at CreateRolePage + and: + page = at(CreateRolePage) + page.authorityField.text = 'test' + page.createButton.click() - when: - authority = 'test' - createButton.click() + and: + page = at(ShowRolePage) then: - at ShowRolePage - authority == 'test' + page.authority == 'test' } void 'edit the details'() { when: - to ListRolePage - roleRow(0).showLink.click() + def page = to(ListRolePage) + page.roleRow(0).showLink.click() + page = at(ShowRolePage) - then: - at ShowRolePage + and: + page.editButton.click() + page = at(EditRolePage) - when: - editButton.click() + and: + page.authorityField.text = 'test_new' + page.updateButton.click() then: - at EditRolePage + at(ShowRolePage) when: - authority = 'test_new' - updateButton.click() + page = to(ListRolePage) then: - at ShowRolePage - - when: - to ListRolePage - - then: - roleRows.size() == 1 - - def row = roleRow(0) - row.authority == 'test_new' + //page.roleRows.size() == 1 + page.roleRow(0).authority == 'test_new' } void 'show role'() { when: - to ListRolePage - roleRow(0).showLink.click() + def page = to(ListRolePage) + page.roleRow(0).showLink.click() then: - at ShowRolePage + at(ShowRolePage) } void 'delete role'() { when: - to ListRolePage - roleRow(0).showLink.click() - def deletedId = id + def page = to(ListRolePage).tap { + roleRow(0).showLink.click() + } + def deletedId = page.id - then: - at ShowRolePage + and: + page = at(ShowRolePage) - when: - withConfirm { deleteButton.click() } + and: + withConfirm { page.deleteButton.click() } - then: - at ListRolePage + and: + page = at(ListRolePage) - message == "TestRole $deletedId deleted" - roleRows.size() == 0 + then: + page.message == "TestRole $deletedId deleted" + page.roleRows.size() == 0 } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy index 30b2c499d..eafb922f9 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy @@ -16,114 +16,105 @@ * specific language governing permissions and limitations * under the License. */ - package specs -import grails.testing.mixin.integration.Integration import pages.user.CreateUserPage import pages.user.EditUserPage import pages.user.ListUserPage import pages.user.ShowUserPage import spock.lang.IgnoreIf +import spock.lang.Stepwise +import grails.testing.mixin.integration.Integration + +@Stepwise @Integration @IgnoreIf({ !( System.getProperty('TESTCONFIG') == 'annotation' || - System.getProperty('TESTCONFIG') == 'basic' || - System.getProperty('TESTCONFIG') == 'basicCacheUsers' || - System.getProperty('TESTCONFIG') == 'requestmap' || - System.getProperty('TESTCONFIG') == 'static') + System.getProperty('TESTCONFIG') == 'basic' || + System.getProperty('TESTCONFIG') == 'basicCacheUsers' || + System.getProperty('TESTCONFIG') == 'requestmap' || + System.getProperty('TESTCONFIG') == 'static') }) class UserSpec extends AbstractSecuritySpec { void 'there are no users initially'() { when: - to ListUserPage + def page = to(ListUserPage) then: - userRows.size() == 0 + page.userRows.size() == 0 } void 'add a user'() { when: - to ListUserPage - newUserButton.click() - - then: - at CreateUserPage + def page = to(ListUserPage) + page.newUserButton.click() - when: - username = 'new_user' - password = 'p4ssw0rd' - $('#enabled').click() - createButton.click() + and: + page = at(CreateUserPage) + page.usernameField.text = 'new_user' + page.passwordField.text = 'p4ssw0rd' + page.enabledCheckbox.check() + page.createButton.click() + page = at(ShowUserPage) then: - at ShowUserPage - username == 'new_user' - userEnabled == true + page.username == 'new_user' + page.userEnabled == true } void 'edit the details'() { when: - to ListUserPage - userRow(0).showLink.click() + def page = to(ListUserPage) + page.userRow(0).showLink.click() - then: - at ShowUserPage + and: + page = at(ShowUserPage) + page.editButton.click() + page = at(EditUserPage) - when: - editButton.click() + and: + page.usernameField.text = 'new_user2' + page.passwordField.text = 'p4ssw0rd2' + page.enabledCheckbox.uncheck() + page.updateButton.click() then: - at EditUserPage + at(ShowUserPage) when: - username = 'new_user2' - password = 'p4ssw0rd2' - $('#enabled').click() - - updateButton.click() + page = to(ListUserPage) then: - at ShowUserPage - - when: - to ListUserPage - - then: - userRows.size() == 1 - - def row = userRow(0) + page.userRows.size() == 1 + def row = page.userRow(0) row.username == 'new_user2' !row.userEnabled } void 'show user'() { when: - to ListUserPage - userRow(0).showLink.click() + def page = to(ListUserPage) + page.userRow(0).showLink.click() then: - at ShowUserPage + at(ShowUserPage) } void 'delete user'() { when: - to ListUserPage - userRow(0).showLink.click() - def deletedId = id + def page = to(ListUserPage) + page.userRow(0).showLink.click() + def deletedId = page.id + page = at(ShowUserPage) - then: - at ShowUserPage - - when: - withConfirm { deleteButton.click() } + and: + withConfirm { page.deleteButton.click() } + page = at(ListUserPage) then: - at ListUserPage - - message == "TestUser $deletedId deleted." - userRows.size() == 0 + page.message == "TestUser $deletedId deleted." + page.userRows.size() == 0 } } 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 index 22a0ad2ca..c4fbc68d6 100644 --- 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 @@ -40,28 +40,6 @@ class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification { ) } - void "SecurityAutoConfiguration bean is not registered"() { - given: - def secAutoConfig = Class.forName( - 'org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration' - ) - - expect: - applicationContext - .getBeanNamesForType(secAutoConfig).length == 0 - } - - void "SecurityFilterAutoConfiguration bean is not registered"() { - given: - def secFilterAutoConfig = Class.forName( - 'org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration' - ) - - expect: - applicationContext - .getBeanNamesForType(secFilterAutoConfig).length == 0 - } - void "no duplicate SecurityFilterChain beans from auto-configuration"() { given: def filterChainBeans = applicationContext diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy index e24bb3f83..1e5c4f328 100644 --- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy @@ -18,7 +18,8 @@ */ package grails.plugin.springsecurity -import org.springframework.security.access.event.AbstractAuthorizationEvent +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.authorization.event.AuthorizationEvent import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.TestingAuthenticationToken import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent @@ -77,12 +78,12 @@ class SecurityEventListenerSpec extends AbstractUnitSpec { called } - void 'Test handling AbstractAuthorizationEvent'() { + void 'Test handling AuthorizationEvent'() { when: boolean called = false closures.onAuthorizationEvent = { e, appCtx -> called = true } - listener.onApplicationEvent new AbstractAuthorizationEvent(42) {} + listener.onApplicationEvent new AuthorizationEvent({ new TestingAuthenticationToken('', '') }, 42, new AuthorizationDecision(true)) then: called diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy index 6348432d6..401edd314 100644 --- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy @@ -218,7 +218,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec { void 'isAjax using SavedRequest, false'() { when: - def savedRequest = new DefaultSavedRequest(request, new PortResolverImpl()) + def savedRequest = new DefaultSavedRequest(request) request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest then: @@ -228,7 +228,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec { void 'isAjax using SavedRequest, true'() { when: request.addHeader 'X-Requested-With', 'true' - def savedRequest = new DefaultSavedRequest(request, new PortResolverImpl()) + def savedRequest = new DefaultSavedRequest(request) request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest then: @@ -238,7 +238,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec { void 'isAjax using SavedRequest, XMLHttpRequest'() { when: request.addHeader 'X-Requested-With', 'XMLHttpRequest' - def savedRequest = new DefaultSavedRequest(request, new PortResolverImpl()) + def savedRequest = new DefaultSavedRequest(request) request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest then: @@ -431,7 +431,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec { private void initRoleHierarchy(String hierarchyString) { defineBeans { - roleHierarchy(RoleHierarchyImpl) { + roleHierarchy(MutableRoleHierarchy) { hierarchy = hierarchyString } } diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy new file mode 100644 index 000000000..b21adf64a --- /dev/null +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy @@ -0,0 +1,71 @@ +/* + * 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.web.access.intercept + +import grails.plugin.springsecurity.AbstractUnitSpec +import org.springframework.security.access.AccessDecisionManager +import org.springframework.security.access.SecurityConfig +import org.springframework.security.web.FilterInvocation +import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource +import org.springframework.security.web.access.intercept.FilterSecurityInterceptor + +import jakarta.servlet.FilterChain + +class FilterSecurityInterceptorSpec extends AbstractUnitSpec { + + void 'doFilter delegates to the chain for public invocations when allowed'() { + given: + def interceptor = new FilterSecurityInterceptor( + rejectPublicInvocations: false, + securityMetadataSource: Stub(FilterInvocationSecurityMetadataSource) { + getAttributes(_ as FilterInvocation) >> null + } + ) + FilterChain chain = Mock() + + when: + interceptor.doFilter(request, response, chain) + + then: + 1 * chain.doFilter(request, response) + } + + void 'doFilter consults the access decision manager before continuing the chain'() { + given: + def attributes = [new SecurityConfig('ROLE_USER')] + def interceptor = new FilterSecurityInterceptor( + rejectPublicInvocations: false, + securityMetadataSource: Stub(FilterInvocationSecurityMetadataSource) { + getAttributes(_ as FilterInvocation) >> attributes + } + ) + interceptor.accessDecisionManager = Mock(AccessDecisionManager) { + 1 * decide(null, _ as FilterInvocation, attributes) + } + FilterChain chain = Mock() + + when: + interceptor.doFilter(request, response, chain) + + then: + 1 * chain.doFilter(request, response) + } +} + + diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy index f55c13b1c..db8294e9b 100644 --- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy @@ -22,6 +22,7 @@ import grails.plugin.springsecurity.AbstractUnitSpec import grails.plugin.springsecurity.ReflectionUtils import grails.plugin.springsecurity.SpringSecurityUtils import grails.plugin.springsecurity.web.SecurityRequestHolder +import org.springframework.security.web.RedirectStrategy /** * Unit tests for WithAjaxAuthenticationProcessingFilterEntryPoint. @@ -60,6 +61,23 @@ class AjaxAwareAuthenticationEntryPointSpec extends AbstractUnitSpec { ajaxLoginFormUrl == response.forwardedUrl } + void 'commence() redirects without legacy portResolver wiring'() { + given: + entryPoint.useForward = false + String redirectedUrl + entryPoint.redirectStrategy = Stub(RedirectStrategy) { + sendRedirect(_, _, _) >> { requestArg, responseArg, url -> + redirectedUrl = url as String + } + } + + when: + entryPoint.commence request, response, null + + then: + loginFormUrl == redirectedUrl + } + void 'setAjaxLoginFormUrl'() { when: entryPoint.ajaxLoginFormUrl = 'foo' diff --git a/plugin-core/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy new file mode 100644 index 000000000..67892e0c2 --- /dev/null +++ b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy @@ -0,0 +1,147 @@ +/* + * 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 org.springframework.security.access.intercept.aopalliance + +import grails.plugin.springsecurity.SecurityTestUtils +import org.aopalliance.aop.Advice +import org.aopalliance.intercept.MethodInvocation +import org.springframework.security.access.AccessDecisionManager +import org.springframework.security.access.SecurityConfig +import org.springframework.security.access.intercept.AfterInvocationManager +import org.springframework.security.access.intercept.RunAsManagerImpl +import org.springframework.security.access.method.MethodSecurityMetadataSource +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import spock.lang.Specification + +class MethodSecurityInterceptorSpec extends Specification { + + void cleanup() { + SecurityTestUtils.logout() + } + + void 'invoke acts as advice and applies access and after-invocation processing'() { + given: + Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER']) + def method = String.getMethod('trim') + def invocation = Stub(MethodInvocation) { + getMethod() >> method + getThis() >> ' ok ' + proceed() >> 'ok' + } + def attributes = [new SecurityConfig('ROLE_USER')] + def interceptor = new MethodSecurityInterceptor( + securityMetadataSource: Stub(MethodSecurityMetadataSource) { + getAttributes(method, String) >> attributes + }, + accessDecisionManager: Mock(AccessDecisionManager) { + 1 * decide(authentication, invocation, attributes) + }, + afterInvocationManager: Mock(AfterInvocationManager) { + 1 * decide(authentication, invocation, attributes, 'ok') >> 'filtered' + } + ) + + expect: + interceptor instanceof Advice + interceptor.invoke(invocation) == 'filtered' + } + + void 'invoke proceeds directly when there are no security attributes'() { + given: + def method = String.getMethod('trim') + def invocation = Stub(MethodInvocation) { + getMethod() >> method + getThis() >> ' ok ' + proceed() >> 'ok' + } + def interceptor = new MethodSecurityInterceptor( + securityMetadataSource: Stub(MethodSecurityMetadataSource) { + getAttributes(method, String) >> null + } + ) + + expect: + interceptor.invoke(invocation) == 'ok' + } + + void 'invoke fails with credentials not found when secured invocation has no authentication'() { + given: + def method = String.getMethod('trim') + def invocation = Stub(MethodInvocation) { + getMethod() >> method + getThis() >> ' ok ' + } + def interceptor = new MethodSecurityInterceptor( + securityMetadataSource: Stub(MethodSecurityMetadataSource) { + getAttributes(method, String) >> [new SecurityConfig('ROLE_USER')] + } + ) + + when: + interceptor.invoke(invocation) + + then: + thrown AuthenticationCredentialsNotFoundException + } + + void 'invoke applies run-as authentication during the secured invocation and restores the original authentication afterwards'() { + given: + Authentication authentication = SecurityTestUtils.authenticate(['ROLE_ADMIN']) + def method = String.getMethod('trim') + def attributes = [new SecurityConfig('ROLE_ADMIN'), new SecurityConfig('RUN_AS_SUPERUSER')] + def interceptor = new MethodSecurityInterceptor( + securityMetadataSource: Stub(MethodSecurityMetadataSource) { + getAttributes(method, String) >> attributes + }, + accessDecisionManager: Mock(AccessDecisionManager) { + 1 * decide(authentication, _ as MethodInvocation, attributes) + }, + runAsManager: new RunAsManagerImpl(), + afterInvocationManager: Mock(AfterInvocationManager) { + 1 * decide(_ as Authentication, _ as MethodInvocation, attributes, 'ok') >> { Authentication activeAuthentication, MethodInvocation ignored, Collection ignoredAttributes, Object returnedObject -> + assert activeAuthentication.authorities*.authority.contains('ROLE_RUN_AS_SUPERUSER') + return returnedObject + } + } + ) + def invocation = Stub(MethodInvocation) { + getMethod() >> method + getThis() >> ' ok ' + proceed() >> { + assert SecurityContextHolder.context.authentication.authorities*.authority.contains('ROLE_RUN_AS_SUPERUSER') + 'ok' + } + } + + when: + def result = interceptor.invoke(invocation) + + then: + result == 'ok' + SecurityContextHolder.context.authentication.is(authentication) + !SecurityContextHolder.context.authentication.authorities*.authority.contains('ROLE_RUN_AS_SUPERUSER') + } +} + + + + + diff --git a/plugin-core/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy new file mode 100644 index 000000000..5b96cf886 --- /dev/null +++ b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy @@ -0,0 +1,60 @@ +/* + * 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 org.springframework.security.access.method + +import org.springframework.security.access.annotation.Secured +import org.springframework.security.access.annotation.SecuredAnnotationSecurityMetadataSource +import spock.lang.Specification + +class AbstractFallbackMethodSecurityMetadataSourceSpec extends Specification { + + private final SecuredAnnotationSecurityMetadataSource metadataSource = new SecuredAnnotationSecurityMetadataSource() + + void 'getAttributes prefers method metadata from the concrete target class over class metadata'() { + given: + def method = SecuredService.getMethod('userAnnotated') + + expect: + metadataSource.getAttributes(method, SecuredServiceImpl)*.attribute == ['ROLE_USER'] + } + + void 'getAttributes falls back to class metadata when the concrete target method has no annotation'() { + given: + def method = SecuredService.getMethod('notAnnotated') + + expect: + metadataSource.getAttributes(method, SecuredServiceImpl)*.attribute == ['ROLE_ADMIN'] + } + + private static interface SecuredService { + void notAnnotated() + void userAnnotated() + } + + @Secured('ROLE_ADMIN') + private static class SecuredServiceImpl implements SecuredService { + @Override + void notAnnotated() {} + + @Override + @Secured('ROLE_USER') + void userAnnotated() {} + } +} + diff --git a/plugin-core/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy new file mode 100644 index 000000000..954d129d0 --- /dev/null +++ b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy @@ -0,0 +1,155 @@ +/* + * 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 org.springframework.security.access.prepost + +import grails.plugin.springsecurity.SecurityTestUtils +import org.aopalliance.intercept.MethodInvocation +import org.springframework.security.access.PermissionEvaluator +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler +import org.springframework.security.access.expression.method.ExpressionBasedAnnotationAttributeFactory +import org.springframework.security.access.expression.method.ExpressionBasedPostInvocationAdvice +import org.springframework.security.access.expression.method.ExpressionBasedPreInvocationAdvice +import org.springframework.security.core.Authentication +import org.springframework.security.core.parameters.P +import spock.lang.Specification + +class MethodSecurityExpressionCompatibilitySpec extends Specification { + + void cleanup() { + SecurityTestUtils.logout() + } + + void 'metadata source extracts pre and post annotations from secured methods'() { + given: + def metadataSource = new PrePostAnnotationSecurityMetadataSource( + new ExpressionBasedAnnotationAttributeFactory(Stub(DefaultMethodSecurityExpressionHandler)) + ) + def method = SecuredService.getMethod('getReports') + + when: + def attribute = metadataSource.getAttributes(method, SecuredService).first() as ExpressionBasedAnnotationConfigAttribute + + then: + attribute.preAuthorizeExpression == 'hasRole("ROLE_USER")' + attribute.postFilterExpression == 'hasPermission(filterObject, read)' + } + + void 'pre-invocation voter denies and grants based on the evaluated pre-authorize expression'() { + given: + PermissionEvaluator permissionEvaluator = Mock() + Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER']) + def advice = new ExpressionBasedPreInvocationAdvice( + expressionHandler: Stub(DefaultMethodSecurityExpressionHandler) { + getPermissionEvaluator() >> permissionEvaluator + } + ) + def voter = new PreInvocationAuthorizationAdviceVoter(advice) + def reflectedMethod = SecuredService.getMethod('getReport', Long) + MethodInvocation invocation = Stub(MethodInvocation) + invocation.getMethod() >> reflectedMethod + invocation.getArguments() >> ([42L] as Object[]) + def attribute = new ExpressionBasedAnnotationConfigAttribute( + 'hasPermission(#id, "com.testacl.Report", read)', null, null, null) + + when: + def denied = voter.vote(authentication, invocation, [attribute]) + + then: + denied == PreInvocationAuthorizationAdviceVoter.ACCESS_DENIED + 1 * permissionEvaluator.hasPermission(authentication, 42L, 'com.testacl.Report', _) >> false + + when: + def granted = voter.vote(authentication, invocation, [attribute]) + + then: + granted == PreInvocationAuthorizationAdviceVoter.ACCESS_GRANTED + 1 * permissionEvaluator.hasPermission(authentication, 42L, 'com.testacl.Report', _) >> true + } + + void 'pre-invocation voter exposes the create ACL permission alias'() { + given: + PermissionEvaluator permissionEvaluator = Mock() + Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER']) + def advice = new ExpressionBasedPreInvocationAdvice( + expressionHandler: Stub(DefaultMethodSecurityExpressionHandler) { + getPermissionEvaluator() >> permissionEvaluator + } + ) + def voter = new PreInvocationAuthorizationAdviceVoter(advice) + def reflectedMethod = SecuredService.getMethod('createReport', Long) + MethodInvocation invocation = Stub(MethodInvocation) + invocation.getMethod() >> reflectedMethod + invocation.getArguments() >> ([42L] as Object[]) + def attribute = new ExpressionBasedAnnotationConfigAttribute( + 'hasPermission(#id, "com.testacl.Report", create)', null, null, null) + + when: + def granted = voter.vote(authentication, invocation, [attribute]) + + then: + granted == PreInvocationAuthorizationAdviceVoter.ACCESS_GRANTED + 1 * permissionEvaluator.hasPermission(authentication, 42L, 'com.testacl.Report', 4) >> true + } + + void 'post-invocation advice filters returned collections using post-filter expressions'() { + given: + PermissionEvaluator permissionEvaluator = Mock() + Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER']) + def advice = new ExpressionBasedPostInvocationAdvice(Stub(DefaultMethodSecurityExpressionHandler) { + getPermissionEvaluator() >> permissionEvaluator + }) + def reflectedMethod = SecuredService.getMethod('getReports') + MethodInvocation invocation = Stub(MethodInvocation) + invocation.getMethod() >> reflectedMethod + invocation.getArguments() >> ([] as Object[]) + def attribute = new ExpressionBasedAnnotationConfigAttribute( + null, null, null, 'hasPermission(filterObject, read)') + def report1 = new Object() + def report2 = new Object() + + when: + def filtered = advice.after(authentication, invocation, attribute, [report1, report2]) + + then: + filtered == [report1] + 1 * permissionEvaluator.hasPermission(authentication, report1, _) >> true + 1 * permissionEvaluator.hasPermission(authentication, report2, _) >> false + } + + private static class SecuredService { + @PreAuthorize('hasPermission(#id, "com.testacl.Report", read)') + Object getReport(@P('id') Long id) { + null + } + + @PreAuthorize('hasPermission(#id, "com.testacl.Report", create)') + Object createReport(@P('id') Long id) { + null + } + + @PreAuthorize('hasRole("ROLE_USER")') + @PostFilter('hasPermission(filterObject, read)') + List getReports() { + [] + } + } +} + + + diff --git a/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy b/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy index 259c9fe51..68c52e9b9 100644 --- a/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy +++ b/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy @@ -82,4 +82,22 @@ class DefaultRestAuthenticationEventPublisherSpec extends Specification { then: 0 * eventPublisher.publishEvent(_) } + + void "should allow null publisher in constructor and remain no-op for token events"() { + when: + new DefaultRestAuthenticationEventPublisher(null).publishTokenCreation(accessToken) + + then: + noExceptionThrown() + 0 * eventPublisher.publishEvent(_) + } + + void "should not execute parent publishEvent when publisher is not set"() { + when: + new DefaultRestAuthenticationEventPublisher().publishAuthenticationSuccess(accessToken) + + then: + noExceptionThrown() + 0 * eventPublisher.publishEvent(_) + } } diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/page/AbstractSecurityPage.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/page/AbstractSecurityPage.groovy index 66151831c..2cbe93709 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/page/AbstractSecurityPage.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/page/AbstractSecurityPage.groovy @@ -23,6 +23,10 @@ import geb.Page abstract class AbstractSecurityPage extends Page { + static content = { + submitBtn { $('input', type: 'submit') } + } + void submit() { submitBtn.click() } diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy index 234a8637f..a73b88587 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy @@ -16,10 +16,8 @@ * specific language governing permissions and limitations * under the License. */ - package spec -import grails.testing.mixin.integration.Integration import page.profile.ProfileCreatePage import page.profile.ProfileEditPage import page.profile.ProfileListPage @@ -30,6 +28,8 @@ import page.register.SecurityQuestionsPage import page.user.UserEditPage import page.user.UserSearchPage +import grails.testing.mixin.integration.Integration + @Integration class RegisterSpec extends AbstractSecuritySpec { diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/page/AbstractSecurityPage.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/page/AbstractSecurityPage.groovy index 66151831c..2cbe93709 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/page/AbstractSecurityPage.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/page/AbstractSecurityPage.groovy @@ -23,6 +23,10 @@ import geb.Page abstract class AbstractSecurityPage extends Page { + static content = { + submitBtn { $('input', type: 'submit') } + } + void submit() { submitBtn.click() } diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/page/register/RegisterPage.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/page/register/RegisterPage.groovy index cb9ff69af..30eb8f00a 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/page/register/RegisterPage.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/page/register/RegisterPage.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package page.register import geb.module.PasswordInput diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegisterSpec.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegisterSpec.groovy index 6da4b24df..1e34c404e 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegisterSpec.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegisterSpec.groovy @@ -16,32 +16,23 @@ * specific language governing permissions and limitations * under the License. */ - package spec -import grails.testing.mixin.integration.Integration +import com.dumbster.smtp.SimpleSmtpServer +import com.dumbster.smtp.SmtpMessage import page.register.ForgotPasswordPage import page.register.RegisterPage import page.register.ResetPasswordPage import page.user.UserEditPage import page.user.UserSearchPage -import com.dumbster.smtp.SimpleSmtpServer -import com.dumbster.smtp.SmtpMessage +import grails.testing.mixin.integration.Integration @Integration class RegisterSpec extends AbstractSecuritySpec { private SimpleSmtpServer server - void setup() { - startMailServer() - } - - void cleanup() { - server.stop() - } - void testRegisterValidation() { when: to(RegisterPage).with { @@ -105,14 +96,14 @@ class RegisterSpec extends AbstractSecuritySpec { void testRegisterAndForgotPassword() { given: - String un = "test_user_abcdef${System.currentTimeMillis()}" - - when: - def registerPage = to(RegisterPage) + startMailServer() and: - registerPage.with { - username = un + def un = "test_user_abcdef${System.currentTimeMillis()}" + + when: + to(RegisterPage).with { + username = un email = "$un@abcdef.com" password = 'aaaaaa1#' password2 = 'aaaaaa1#' @@ -121,22 +112,17 @@ class RegisterSpec extends AbstractSecuritySpec { then: pageSource.contains('Your account registration email was sent - check your mail!') - 1 == server.receivedEmailSize - - when: - def email = currentEmail - - then: - 'New Account' == email.getHeaderValue('Subject') + server.receivedEmailSize == 1 when: - String body = email.body + def smtpMessage = currentEmail then: - body.contains("Hi $un") + 'New Account' == smtpMessage.getHeaderValue('Subject') + smtpMessage.body.contains("Hi $un") when: - String code = findCode(body, 'verifyRegistration') + String code = findCode(smtpMessage.body, 'verifyRegistration') then: code ==~ /^[a-f0-9]{32}$/ @@ -162,22 +148,18 @@ class RegisterSpec extends AbstractSecuritySpec { then: pageSource.contains('Your password reset email was sent - check your mail!') - 2 == server.receivedEmailSize + server.receivedEmailSize == 2 when: - email = currentEmail + smtpMessage = currentEmail then: - 'Password Reset' == email.getHeaderValue('Subject') + smtpMessage.getHeaderValue('Subject') == 'Password Reset' - when: - body = email.body - - then: - body.contains("Hi $un") + smtpMessage.body.contains("Hi $un") when: - code = findCode(body, 'resetPassword') + code = findCode(smtpMessage.body, 'resetPassword') go('register/resetPassword?t=123') then: @@ -247,6 +229,10 @@ class RegisterSpec extends AbstractSecuritySpec { then: pageSource.contains('User not found') + + cleanup: + server?.stop() + server = null } private SmtpMessage getCurrentEmail() { @@ -258,11 +244,11 @@ class RegisterSpec extends AbstractSecuritySpec { return email as SmtpMessage } - private String findCode(String body, String action) { + private static String findCode(String body, String action) { def matcher = body =~ /(?s).*$action\?t=(.+)".*/ - assert matcher.hasGroup() - assert matcher.count == 1 - matcher[0][1] + assert matcher.find() + assert matcher.groupCount() == 1 + matcher.group(1) } private void startMailServer() { @@ -279,6 +265,6 @@ class RegisterSpec extends AbstractSecuritySpec { } server = SimpleSmtpServer.start(port) go("testData/updateMailSenderPort?port=$port") - pageSource.contains("OK: $port") + assert pageSource.contains("OK: $port") } } From 48a381f18805dadd7eee10b661438773e5db7267 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 13:06:25 +0200 Subject: [PATCH 07/29] chore: add missing license header --- .../groovy/pages/AccessDeniedPage.groovy | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy index fcf38042a..39036bfd4 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy @@ -1,3 +1,21 @@ +/* + * 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 pages import geb.Page From dbd6fa60bae70a800587009897d33697c2886561 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Fri, 17 Apr 2026 14:12:06 +0200 Subject: [PATCH 08/29] test: update flaky tests --- .../groovy/specs/BasicAuthSecuritySpec.groovy | 2 +- .../RegistrationCodeEditPage.groovy | 5 + .../groovy/page/role/RoleSearchPage.groovy | 2 +- .../groovy/spec/RegisterSpec.groovy | 129 ++++++------ .../groovy/spec/RegistrationCodeSpec.groovy | 76 +++---- .../groovy/spec/RoleSpec.groovy | 62 +++--- .../groovy/spec/UserSpec.groovy | 140 +++++++------ .../groovy/test/ProfileServiceSpec.groovy | 3 + .../RegistrationCodeEditPage.groovy | 6 + .../groovy/page/user/UserEditPage.groovy | 5 + .../groovy/spec/AbstractSecuritySpec.groovy | 1 - .../groovy/spec/RegistrationCodeSpec.groovy | 86 ++++---- .../groovy/spec/UserSimpleSpec.groovy | 185 ++++++++++-------- 13 files changed, 393 insertions(+), 309 deletions(-) diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy index ea78afaec..98f1c2e8b 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy @@ -211,7 +211,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec { login('admin1', 'password1') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy index 4976aa738..586576234 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy @@ -26,6 +26,11 @@ class RegistrationCodeEditPage extends EditPage { static url = 'registrationCode/edit' static typeName = { 'RegistrationCode' } + + String convertToPath(Object[] args) { + args ? "/${args[0]}" : '' + } + static content = { token { $(name: 'token').module(TextInput) } username { $('#username').module(TextInput) } diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/page/role/RoleSearchPage.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/page/role/RoleSearchPage.groovy index 29283e894..f1f0c4528 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/page/role/RoleSearchPage.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/page/role/RoleSearchPage.groovy @@ -32,7 +32,7 @@ class RoleSearchPage extends SearchPage { } void search(String q) { - authority = q + authority.text = q submit() } } diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy index a73b88587..7dabbff8b 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegisterSpec.groovy @@ -35,79 +35,86 @@ class RegisterSpec extends AbstractSecuritySpec { void testRegisterValidation() { when: - to(RegisterPage).with { + def page = to(RegisterPage).tap { submit() } - def registerPage = at(RegisterPage) then: - pageSource.contains('Username is required') - pageSource.contains('Email is required') - pageSource.contains('Password is required') + waitFor { // We end up back at the register page, but we need to wait for the validation errors to be rendered + pageSource.contains('Username is required') + pageSource.contains('Email is required') + pageSource.contains('Password is required') + } when: - registerPage.with { - username = 'admin' - email = 'foo' - password = 'abcdefghijk' - password2 = 'mnopqrstuwzy' + page.with { + username.text = 'admin' + email.text = 'foo' + password.text = 'abcdefghijk' + password2.text = 'mnopqrstuwzy' submit() } - registerPage = at(RegisterPage) then: - pageSource.contains('The username is taken') - pageSource.contains('Please provide a valid email address') - pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') - pageSource.contains('Passwords do not match') + waitFor { // We end up back at the register page, but we need to wait for the validation errors to be rendered + pageSource.contains('The username is taken') + pageSource.contains('Please provide a valid email address') + pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') + pageSource.contains('Passwords do not match') + } when: - registerPage.with { - username = 'abcdef123' - email = 'abcdef@abcdef.com' - password = 'aaaaaaaa' - password2 = 'aaaaaaaa' + page.with { + username.text = 'abcdef123' + email.text = 'abcdef@abcdef.com' + password.text = 'aaaaaaaa' + password2.text = 'aaaaaaaa' submit() } then: - pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') + waitFor { // We end up back at the register page, but we need to wait for the validation errors to be rendered + pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') + } } void testForgotPasswordValidation() { when: - to(ForgotPasswordPage).with { + def page = to(ForgotPasswordPage).tap { submit() } - def forgotPasswordPage = at(ForgotPasswordPage) then: - pageSource.contains('Please enter your username') + at(ForgotPasswordPage) + waitFor { // We end up back at the forgot password page, but we need to wait for the validation errors to be rendered + pageSource.contains('Please enter your username') + } when: - forgotPasswordPage.with { - username = '1111' + page.with { + username.text = '1111' submit() } then: at(ForgotPasswordPage) - pageSource.contains('No user was found with that username') + waitFor { // We end up back at the forgot password page, but we need to wait for the validation errors to be rendered + pageSource.contains('No user was found with that username') + } } void testRegisterAndForgotPassword() { given: - String un = "test_user_abcdef${System.currentTimeMillis()}" + def un = "test_user_abcdef${System.currentTimeMillis()}" when: go('register/resetPassword?t=123') then: - pageSource.contains('Sorry, we have no record of that request, or it has expired') + waitFor { pageSource.contains('Sorry, we have no record of that request, or it has expired') } when: - def registerPage = to(RegisterPage) - registerPage.with { + to(RegisterPage).with { username = un email = "$un@abcdef.com" password = 'aaaaaa1#' @@ -122,17 +129,17 @@ class RegisterSpec extends AbstractSecuritySpec { to(ProfileCreatePage).with { create(un) } - def listPage = at(ProfileListPage) + def page = at(ProfileListPage) then: pageSource.contains('created') when: - listPage.editProfile(un) - def profileEditPage = at(ProfileEditPage) + page.editProfile(un) + page = at(ProfileEditPage) and: - profileEditPage.updateProfile(un) + page.updateProfile(un) then: at(ProfileListPage) @@ -143,10 +150,10 @@ class RegisterSpec extends AbstractSecuritySpec { go('') then: - pageSource.contains('Log in') + waitFor { pageSource.contains('Log in') } when: - to(ForgotPasswordPage).with { + via(ForgotPasswordPage).with { username = un submit() } @@ -156,33 +163,37 @@ class RegisterSpec extends AbstractSecuritySpec { when: securityQuestionPage.with { - question1 = '1234' - question2 = '12345' + question1.text = '1234' + question2.text = '12345' submit() } - def resetPasswordPage = browser.at(ResetPasswordPage) + page = at(ResetPasswordPage) and: - resetPasswordPage.submit() + page.submit() then: - pageSource.contains('Password is required') + waitFor { pageSource.contains('Password is required') } when: - resetPasswordPage.enterNewPassword('abcdefghijk','mnopqrstuwzy') + page.enterNewPassword('abcdefghijk','mnopqrstuwzy') then: - pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') - pageSource.contains('Passwords do not match') + waitFor { + pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') + pageSource.contains('Passwords do not match') + } when: - resetPasswordPage.enterNewPassword('aaaaaaaa', 'aaaaaaaa') + page.enterNewPassword('aaaaaaaa', 'aaaaaaaa') then: - pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') + waitFor { + pageSource.contains('Password must have at least one letter, number, and special character: !@#$%^&') + } when: - resetPasswordPage.enterNewPassword('aaaaaa1#', 'aaaaaa1#') + page.enterNewPassword('aaaaaa1#', 'aaaaaa1#') then: waitFor { pageSource.contains('Your password was successfully changed') } @@ -192,32 +203,30 @@ class RegisterSpec extends AbstractSecuritySpec { go('') then: - pageSource.contains('Log in') + waitFor { pageSource.contains('Log in') } when: - to(ProfileListPage) + page = to(ProfileListPage) and: - listPage.editProfile(un) + page.editProfile(un) + page = at(ProfileEditPage) - then: - def profileEditPage2 = browser.at(ProfileEditPage) - - when: - profileEditPage2.deleteProfile() + and: + page.deleteProfile() then: waitFor { pageSource.contains('deleted') } when: go("user/edit?username=$un") + page = at(UserEditPage) then: - def userEditPage = at(UserEditPage) - userEditPage.username.text == un + page.username.text == un when: - userEditPage.delete() + page.delete() then: at(UserSearchPage) @@ -226,6 +235,6 @@ class RegisterSpec extends AbstractSecuritySpec { go("user/edit?username=$un") then: - pageSource.contains('User not found') + waitFor { pageSource.contains('User not found') } } } \ No newline at end of file diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy index 573bcc8e2..7082a38aa 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy @@ -16,88 +16,98 @@ * specific language governing permissions and limitations * under the License. */ - package spec -import grails.testing.mixin.integration.Integration import page.registrationCode.RegistrationCodeEditPage import page.registrationCode.RegistrationCodeSearchPage +import spock.lang.Stepwise +import grails.testing.mixin.integration.Integration + +@Stepwise @Integration class RegistrationCodeSpec extends AbstractSecuritySpec { void testFindAll() { when: - def searchPage = to(RegistrationCodeSearchPage) + def page = to(RegistrationCodeSearchPage) then: - searchPage.assertNotSearched() + page.assertNotSearched() when: - searchPage.submit() - searchPage = at(RegistrationCodeSearchPage) + page.submit() + page = at(RegistrationCodeSearchPage) then: - searchPage.assertResults(1, 10, 14) - pageSource.contains('registration_test_2') - pageSource.contains('0a154624f36d42e4aa68991a9477bd04') + page.assertResults(1, 10, 14) + with(pageSource) { + contains('registration_test_2') + contains('0a154624f36d42e4aa68991a9477bd04') + } } void testFindByToken() { when: - to(RegistrationCodeSearchPage).with { + def page = to(RegistrationCodeSearchPage).tap { token = '4a7f88afec3746f7aab2f5d0d8df6d8e' submit() } - def searchPage = at(RegistrationCodeSearchPage) then: - searchPage.assertResults(1, 1, 1) - pageSource.contains('registration_test_1') - pageSource.contains('4a7f88afec3746f7aab2f5d0d8df6d8e') + page.assertResults(1, 1, 1) + with(pageSource) { + contains('registration_test_1') + contains('4a7f88afec3746f7aab2f5d0d8df6d8e') + } } void testFindByUsername() { when: - to(RegistrationCodeSearchPage).tap { + def page = to(RegistrationCodeSearchPage).tap { username = 'registration_test_3' submit() } - def searchPage = at(RegistrationCodeSearchPage) then: - searchPage.assertResults(1, 5, 5) - pageSource.contains('registration_test_3') - pageSource.contains('89f9bbc658b14808ae4c77c6e17e551a') + page.assertResults(1, 5, 5) + with(pageSource) { + contains('registration_test_3') + contains('89f9bbc658b14808ae4c77c6e17e551a') + } } void testEdit() { when: - go('registrationCode/edit/4') - def editPage = at(RegistrationCodeEditPage) + def page = to(RegistrationCodeEditPage, 4) then: - editPage.username.text == 'registration_test_1' - editPage.token.text == 'a50e061e0e2f424fb7fbc2ff3dae597d' + with(page) { + username.text == 'registration_test_1' + token.text == 'a50e061e0e2f424fb7fbc2ff3dae597d' + } when: - editPage.with { - username = 'new_user' - token = 'new_token' + page.with { + username.text = 'new_user' + token.text = 'new_token' submit() } - editPage = at(RegistrationCodeEditPage) + page = at(RegistrationCodeEditPage) then: - editPage.username.text == 'new_user' - editPage.token.text == 'new_token' + with(page) { + username.text == 'new_user' + token.text == 'new_token' + } when: - go('registrationCode/edit/4') - editPage = at(RegistrationCodeEditPage) + page = to(RegistrationCodeSearchPage, 4) then: - editPage.username.text == 'new_user' - editPage.token.text == 'new_token' + with(page) { + username.text == 'new_user' + token.text == 'new_token' + } } } diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RoleSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RoleSpec.groovy index 3bf6a02f8..9b88bd513 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RoleSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RoleSpec.groovy @@ -16,44 +16,47 @@ * specific language governing permissions and limitations * under the License. */ - package spec -import grails.testing.mixin.integration.Integration import page.role.RoleCreatePage import page.role.RoleEditPage import page.role.RoleSearchPage +import grails.testing.mixin.integration.Integration + @Integration class RoleSpec extends AbstractSecuritySpec { void testFindAll() { when: - def searchPage = to(RoleSearchPage) + def page = to(RoleSearchPage) then: - searchPage.assertNotSearched() + page.assertNotSearched() when: - searchPage.submit() - searchPage = at(RoleSearchPage) + page.submit() + page = at(RoleSearchPage) then: - searchPage.assertResults(1, 10, 12) - pageSource.contains('ROLE_COFFEE') + waitFor { + page.assertResults(1, 10, 12) + pageSource.contains('ROLE_COFFEE') + } } void testFindByAuthority() { when: - to(RoleSearchPage).with { + def page = to(RoleSearchPage).tap { search('ad') } - def searchPage = at(RoleSearchPage) then: - searchPage.assertResults(1, 2, 2) - pageSource.contains('ROLE_ADMIN') - pageSource.contains('ROLE_INSTEAD') + waitFor { + page.assertResults(1, 2, 2) + pageSource.contains('ROLE_ADMIN') + pageSource.contains('ROLE_INSTEAD') + } } void testUniqueName() { @@ -63,52 +66,51 @@ class RoleSpec extends AbstractSecuritySpec { } then: - at(RoleCreatePage) - pageSource.contains('must be unique') + waitFor { pageSource.contains('must be unique') } + } void testCreateAndEdit() { given: - String newName = "ROLE_NEW_TEST${UUID.randomUUID()}" + def newName = "ROLE_NEW_TEST${UUID.randomUUID()}" // make sure it doesn't exist when: - to(RoleSearchPage).tap { + def page = to(RoleSearchPage).tap { search(newName) } - def searchPage = at(RoleSearchPage) then: - searchPage.assertNoResults() + waitFor { page.assertNoResults() } // create when: - to(RoleCreatePage).with { + via(RoleCreatePage).with { create(newName) } - def editPage = at(RoleEditPage) + page = at(RoleEditPage) then: - editPage.authority.text == newName + page.authority.text == newName // edit when: - editPage.update("${newName}_new") - editPage = at(RoleEditPage) + page.update("${newName}_new") + page = at(RoleEditPage) then: - editPage.authority.text == "${newName}_new" + waitFor { page.authority.text == "${newName}_new" } // delete when: - editPage.delete() - searchPage = at(RoleSearchPage) + page.delete() + page = at(RoleSearchPage) and: - searchPage.search("${newName}_new") - searchPage = at(RoleSearchPage) + page.search("${newName}_new") + page = at(RoleSearchPage) then: - searchPage.assertNoResults() + waitFor { page.assertNoResults() } } } diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy index 047855ce7..ed708d771 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy @@ -16,126 +16,134 @@ * specific language governing permissions and limitations * under the License. */ - package spec -import grails.testing.mixin.integration.Integration import page.user.UserCreatePage import page.user.UserEditPage import page.user.UserSearchPage +import spock.lang.Stepwise + +import grails.testing.mixin.integration.Integration +@Stepwise @Integration class UserSpec extends AbstractSecuritySpec { void testFindAll() { when: - def searchPage = to(UserSearchPage) + def page = to(UserSearchPage) then: - searchPage.assertNotSearched() + page.assertNotSearched() when: - searchPage.submit() + page.submit() then: - searchPage.assertResults(1, 10, 22) + page.assertResults(1, 10, 22) } void testFindByUsername() { when: - to(UserSearchPage).with { - username = 'foo' + def page = to(UserSearchPage).tap { + username.text = 'foo' submit() } - def searchPage = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) - pageSource.contains('foon_2') - pageSource.contains('foolkiller') - pageSource.contains('foostra') + page.assertResults(1, 3, 3) + with(pageSource) { + contains('foon_2') + contains('foolkiller') + contains('foostra') + } } void testFindByDisabled() { when: - def searchPage = to(UserSearchPage) + def page = to(UserSearchPage) // Temporary workaround for problem with Geb RadioButtons module //searchPage.enabled.checked = '-1' $('input', type: 'radio', name: 'enabled', value: '-1').click() - searchPage.submit() - searchPage = at(UserSearchPage) + page.submit() + page = at(UserSearchPage) then: - searchPage.assertResults(1, 1, 1) + page.assertResults(1, 1, 1) pageSource.contains('billy9494') } void testFindByAccountExpired() { when: - def searchPage = to(UserSearchPage) + def page = to(UserSearchPage) // Temporary workaround for problem with Geb RadioButtons module //searchPage.accountExpired.checked = '1' $('input', type: 'radio', name: 'accountExpired', value: '1').click() - searchPage.submit() - searchPage = at(UserSearchPage) + page.submit() + page = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) - pageSource.contains('maryrose') - pageSource.contains('ratuig') - pageSource.contains('rome20c') + page.assertResults(1, 3, 3) + with(pageSource) { + contains('maryrose') + contains('ratuig') + contains('rome20c') + } } void testFindByAccountLocked() { when: - def searchPage = to(UserSearchPage) + def page = to(UserSearchPage) // Temporary workaround for problem with Geb RadioButtons module //searchPage.accountLocked.checked = '1' $('input', type: 'radio', name: 'accountLocked', value: '1').click() - searchPage.submit() - searchPage = at(UserSearchPage) + page.submit() + page = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) - pageSource.contains('aaaaaasd') - pageSource.contains('achen') - pageSource.contains('szhang1999') + page.assertResults(1, 3, 3) + with(pageSource) { + contains('aaaaaasd') + contains('achen') + contains('szhang1999') + } } void testFindByPasswordExpired() { when: - def searchPage = to(UserSearchPage) + def page = to(UserSearchPage) // Temporary workaround for problem with Geb RadioButtons module //searchPage.passwordExpired.checked = '1' $('input', type: 'radio', name: 'passwordExpired', value: '1').click() - searchPage.submit() - searchPage = at(UserSearchPage) + page.submit() + page = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) - pageSource.contains('hhheeeaaatt') - pageSource.contains('mscanio') - pageSource.contains('kittal') + page.assertResults(1, 3, 3) + with(pageSource) { + contains('hhheeeaaatt') + contains('mscanio') + contains('kittal') + } } void testCreateAndEdit() { given: - String newUsername = "newuser${UUID.randomUUID()}" + def newUsername = "newuser${UUID.randomUUID()}" // make sure it doesn't exist when: - to(UserSearchPage).with { + def page = to(UserSearchPage).tap { username = newUsername submit() } - def searchPage = at(UserSearchPage) then: - searchPage.assertNoResults() + page.assertNoResults() // create when: @@ -145,46 +153,52 @@ class UserSpec extends AbstractSecuritySpec { enabled.check() submit() } - def editPage = at(UserEditPage) + page = at(UserEditPage) then: - editPage.username.text == newUsername - editPage.enabled.checked - !editPage.accountExpired.checked - !editPage.accountLocked.checked - !editPage.passwordExpired.checked + with(page) { + username.text == newUsername + enabled.checked + !accountExpired.checked + !accountLocked.checked + !passwordExpired.checked + } // edit when: - String updatedName = "${newUsername}_updated" - editPage.with { - username = updatedName + def updatedName = "${newUsername}_updated" + page.with { + username.text = updatedName enabled.uncheck() accountExpired.check() accountLocked.check() passwordExpired.check() submit() } - editPage = at(UserEditPage) + page = at(UserEditPage) then: - editPage.username.text == updatedName - !editPage.enabled.checked - editPage.accountExpired.checked - editPage.accountLocked.checked - editPage.passwordExpired.checked + with(page) { + username.text == updatedName + !enabled.checked + accountExpired.checked + accountLocked.checked + passwordExpired.checked + } // delete when: - editPage.delete() - searchPage = at(UserSearchPage) + page.delete() + page = at(UserSearchPage) and: - searchPage.username = updatedName - searchPage.submit() - searchPage = at(UserSearchPage) + page.with { + username.text = updatedName + submit() + } + page = at(UserSearchPage) then: - searchPage.assertNoResults() + page.assertNoResults() } } diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/test/ProfileServiceSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/test/ProfileServiceSpec.groovy index ec59a00ac..afdd604ce 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/test/ProfileServiceSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/test/ProfileServiceSpec.groovy @@ -19,12 +19,15 @@ package test +import spock.lang.Stepwise + import grails.gorm.transactions.Rollback import grails.testing.mixin.integration.Integration import spock.lang.Specification import org.hibernate.SessionFactory @Rollback +@Stepwise @Integration class ProfileServiceSpec extends Specification { diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy index b29b9c1d2..2856bd573 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/page/registrationCode/RegistrationCodeEditPage.groovy @@ -27,6 +27,12 @@ class RegistrationCodeEditPage extends EditPage { static url = 'registrationCode/edit' static typeName = { 'RegistrationCode' } static at = { title == 'Edit RegistrationCode' } + + + String convertToPath(Object[] args) { + args ? "/${args[0]}" : '' + } + static content = { token { $(name: 'token').module(TextInput) } username { $('#username').module(TextInput) } diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy index c264c3a60..fd1098223 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy @@ -29,6 +29,11 @@ class UserEditPage extends EditPage { static url = 'user/edit' static typeName = { 'User' } static at = { title == 'Edit User' } + + String convertToPath(Object[] args) { + args ? "/${args[0]}" : '' + } + static content = { username { $('#username').module(TextInput) } enabled { $(name: 'enabled').module(Checkbox) } diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/spec/AbstractSecuritySpec.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/spec/AbstractSecuritySpec.groovy index 7389ec8c7..e7981dd27 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/spec/AbstractSecuritySpec.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/spec/AbstractSecuritySpec.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package spec import geb.driver.CachingDriverFactory diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy index 4c122ca2b..2ef472b1e 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy @@ -16,86 +16,98 @@ * specific language governing permissions and limitations * under the License. */ - package spec -import grails.testing.mixin.integration.Integration import page.registrationCode.RegistrationCodeEditPage import page.registrationCode.RegistrationCodeSearchPage +import grails.testing.mixin.integration.Integration + @Integration class RegistrationCodeSpec extends AbstractSecuritySpec { void testFindAll() { when: - def searchPage = to(RegistrationCodeSearchPage) + def page = to(RegistrationCodeSearchPage) then: - searchPage.assertNotSearched() + page.assertNotSearched() when: - searchPage.submit() - searchPage = at(RegistrationCodeSearchPage) + page.submit() + page = at(RegistrationCodeSearchPage) then: - searchPage.assertResults(1, 10, 14) - pageSource.contains('registration_test_2') - pageSource.contains('0a154624f36d42e4aa68991a9477bd04') + waitFor { + page.assertResults(1, 10, 14) + pageSource.contains('registration_test_2') + pageSource.contains('0a154624f36d42e4aa68991a9477bd04') + } } void testFindByToken() { when: - def searchPage = to(RegistrationCodeSearchPage) - searchPage.token = '4a7f88afec3746f7aab2f5d0d8df6d8e' - searchPage.submit() - searchPage = at(RegistrationCodeSearchPage) + def page = to(RegistrationCodeSearchPage).tap { + token.text = '4a7f88afec3746f7aab2f5d0d8df6d8e' + submit() + } + page = at(RegistrationCodeSearchPage) then: - searchPage.assertResults(1, 1, 1) - pageSource.contains('registration_test_1') - pageSource.contains('4a7f88afec3746f7aab2f5d0d8df6d8e') + waitFor { + page.assertResults(1, 1, 1) + pageSource.contains('registration_test_1') + pageSource.contains('4a7f88afec3746f7aab2f5d0d8df6d8e') + } } void testFindByUsername() { when: - def searchPage = to(RegistrationCodeSearchPage) - searchPage.username = 'registration_test_3' - searchPage.submit() - searchPage = at(RegistrationCodeSearchPage) + def page = to(RegistrationCodeSearchPage).tap { + username.text = 'registration_test_3' + submit() + } + page = at(RegistrationCodeSearchPage) then: - searchPage.assertResults(1, 5, 5) - pageSource.contains('registration_test_3') - pageSource.contains('89f9bbc658b14808ae4c77c6e17e551a') + waitFor { + page.assertResults(1, 5, 5) + pageSource.contains('registration_test_3') + pageSource.contains('89f9bbc658b14808ae4c77c6e17e551a') + } } void testEdit() { when: - go('registrationCode/edit/4') - def editPage = at(RegistrationCodeEditPage) + def page = to(RegistrationCodeEditPage, 4) then: - editPage.username.text == 'registration_test_1' - editPage.token.text == 'a50e061e0e2f424fb7fbc2ff3dae597d' + with(page) { + username.text == 'registration_test_1' + token.text == 'a50e061e0e2f424fb7fbc2ff3dae597d' + } when: - editPage.with { - username = 'new_user' - token = 'new_token' + page.with { + username.text = 'new_user' + token.text = 'new_token' submit() } - editPage = at(RegistrationCodeEditPage) + page = at(RegistrationCodeEditPage) then: - editPage.username.text == 'new_user' - editPage.token.text == 'new_token' + waitFor { + page.username.text == 'new_user' + page.token.text == 'new_token' + } when: - go('registrationCode/edit/4') - editPage = at(RegistrationCodeEditPage) + page = to(RegistrationCodeEditPage, 4) then: - editPage.username.text == 'new_user' - editPage.token.text == 'new_token' + waitFor { + page.username.text == 'new_user' + page.token.text == 'new_token' + } } } diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy index 345e26e52..dd2466b76 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - package spec import grails.testing.mixin.integration.Integration @@ -30,17 +29,17 @@ class UserSimpleSpec extends AbstractSecuritySpec { void testFindAll() { when: - def searchPage = to(UserSearchPage) + def page = to(UserSearchPage) then: - searchPage.assertNotSearched() + page.assertNotSearched() when: - searchPage.submit() - searchPage = at(UserSearchPage) + page.submit() + page = at(UserSearchPage) then: - searchPage.assertResults(1, 10, 22) + page.assertResults(1, 10, 22) } void testFindByUsername() { @@ -49,27 +48,29 @@ class UserSimpleSpec extends AbstractSecuritySpec { username = 'foo' submit() } - def searchPage = at(UserSearchPage) + def page = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) - pageSource.contains('foon_2') - pageSource.contains('foolkiller') - pageSource.contains('foostra') + page.assertResults(1, 3, 3) + with(pageSource) { + contains('foon_2') + contains('foolkiller') + contains('foostra') + } } void testFindByDisabled() { when: - def searchPage = to(UserSearchPage) + def page = to(UserSearchPage) // Temporary workaround for problem with Geb RadioButtons module //searchPage.enabled.checked = '-1' $('input', type: 'radio', name: 'enabled', value: '-1').click() - searchPage.submit() - searchPage = at(UserSearchPage) + page.submit() + page = at(UserSearchPage) then: - searchPage.assertResults(1, 1, 1) + page.assertResults(1, 1, 1) pageSource.contains('billy9494') } @@ -85,9 +86,11 @@ class UserSimpleSpec extends AbstractSecuritySpec { then: searchPage.assertResults(1, 3, 3) - pageSource.contains('maryrose') - pageSource.contains('ratuig') - pageSource.contains('rome20c') + with(pageSource) { + contains('maryrose') + contains('ratuig') + contains('rome20c') + } } void testFindByAccountLocked() { @@ -102,9 +105,11 @@ class UserSimpleSpec extends AbstractSecuritySpec { then: searchPage.assertResults(1, 3, 3) - pageSource.contains('aaaaaasd') - pageSource.contains('achen') - pageSource.contains('szhang1999') + with(pageSource) { + contains('aaaaaasd') + contains('achen') + contains('szhang1999') + } } void testFindByPasswordExpired() { @@ -119,158 +124,172 @@ class UserSimpleSpec extends AbstractSecuritySpec { then: searchPage.assertResults(1, 3, 3) - pageSource.contains('hhheeeaaatt') - pageSource.contains('mscanio') - pageSource.contains('kittal') + pageSource.with { + contains('hhheeeaaatt') + contains('mscanio') + contains('kittal') + } } void testCreateAndEdit() { given: - String newUsername = "newuser${UUID.randomUUID()}" + def newUsername = "newuser${UUID.randomUUID()}" // make sure it doesn't exist when: - to(UserSearchPage).with { + def page = to(UserSearchPage).tap { username = newUsername submit() } - def searchPage = at(UserSearchPage) then: - searchPage.assertNoResults() + page.assertNoResults() // create when: - to(UserCreatePage).with { + to(UserCreatePage).tap { username = newUsername password = 'password' enabled.check() submit() } - def editPage = at(UserEditPage) + page = at(UserEditPage) then: - editPage.username.text == newUsername - editPage.enabled.checked - !editPage.accountExpired.checked - !editPage.accountLocked.checked - !editPage.passwordExpired.checked + with(page) { + username.text == newUsername + enabled.checked + !accountExpired.checked + !accountLocked.checked + !passwordExpired.checked + } // edit when: - String updatedName = "${newUsername}_updated" - editPage.with { - username = updatedName + def updatedName = "${newUsername}_updated" + page.with { + username.text = updatedName enabled.uncheck() accountExpired.check() accountLocked.check() passwordExpired.check() submit() } - editPage = at(UserEditPage) + page = at(UserEditPage) then: - editPage.username.text == updatedName - !editPage.enabled.checked - editPage.accountExpired.checked - editPage.accountLocked.checked - editPage.passwordExpired.checked + with(page) { + username.text == updatedName + !enabled.checked + accountExpired.checked + accountLocked.checked + passwordExpired.checked + } + // delete when: - editPage.delete() - searchPage = at(UserSearchPage) + page.delete() + page = at(UserSearchPage) and: - searchPage.with { + page.with { username = updatedName submit() } - searchPage = at(UserSearchPage) + page = at(UserSearchPage) then: - searchPage.assertNoResults() + page.assertNoResults() } @Issue('https://github.com/grails-plugins/grails-spring-security-ui/issues/89') void testUserRoleAssociationsAreNotRemoved() { when: 'edit user 1' - go('user/edit/1') - def editPage = at(UserEditPage) + def page = to(UserEditPage, 1) and: 'select Roles tab' - editPage.rolesTab.select() + page.rolesTab.select() then: '12 roles are listed and 1 is enabled' - editPage.rolesTab.totalRoles() == 12 - editPage.rolesTab.totalEnabledRoles() == 1 - editPage.rolesTab.hasEnabledRole('ROLE_USER') + with(page.rolesTab) { + totalRoles() == 12 + totalEnabledRoles() == 1 + hasEnabledRole('ROLE_USER') + } when: 'ROLE_ADMIN is enabled and the changes are saved' - editPage.with { - rolesTab.enableRole 'ROLE_ADMIN' + page.with { + rolesTab.enableRole('ROLE_ADMIN') submit() rolesTab.select() } then: '12 roles are listed and 2 are enabled' - editPage.rolesTab.totalEnabledRoles() == 2 - editPage.rolesTab.hasEnabledRoles(['ROLE_USER', 'ROLE_ADMIN']) - editPage.rolesTab.totalRoles() == 12 + with(page.rolesTab) { + totalEnabledRoles() == 2 + hasEnabledRoles(['ROLE_USER', 'ROLE_ADMIN']) + totalRoles() == 12 + } } @Issue('https://github.com/grails-plugins/grails-spring-security-ui/issues/106') void testUserRoleAssociationsAreRemoved() { when: 'edit user 2' - go('user/edit/2') - def editPage = at(UserEditPage) + def page = to(UserEditPage, 2) and: 'select Roles tab' - editPage.rolesTab.select() + page.rolesTab.select() then: '12 roles are listed and 1 is enabled' - editPage.rolesTab.totalRoles() == 12 - editPage.rolesTab.totalEnabledRoles() == 1 - editPage.rolesTab.hasEnabledRole('ROLE_USER') + with(page.rolesTab) { + totalRoles() == 12 + totalEnabledRoles() == 1 + hasEnabledRole('ROLE_USER') + } when: 'ROLE_ADMIN is enabled and the changes are saved' - editPage.with { + page.with { rolesTab.enableRole('ROLE_ADMIN') submit() rolesTab.select() } then: '12 roles are listed and 2 are enabled' - editPage.rolesTab.totalEnabledRoles() == 2 - editPage.rolesTab.hasEnabledRoles(['ROLE_USER', 'ROLE_ADMIN']) - editPage.rolesTab.totalRoles() == 12 + with(page.rolesTab) { + totalEnabledRoles() == 2 + hasEnabledRoles(['ROLE_USER', 'ROLE_ADMIN']) + totalRoles() == 12 + } when: 'edit user 2' - go('user/edit/2') - editPage = at(UserEditPage) + page = to(UserEditPage, 2) and: 'select Roles tab' - editPage.rolesTab.select() + page.rolesTab.select() then: '12 roles are listed and 2 are enabled' - editPage.rolesTab.totalRoles() == 12 - editPage.rolesTab.totalEnabledRoles() == 2 - editPage.rolesTab.hasEnabledRole('ROLE_USER') + with(page.rolesTab) { + totalRoles() == 12 + totalEnabledRoles() == 2 + hasEnabledRole('ROLE_USER') + } when: 'ROLE_ADMIN is disabled and the changes are saved' - editPage.with { + page.with { rolesTab.disableRole('ROLE_ADMIN') submit() } - go('user/edit/2') - editPage = at(UserEditPage) + page = to(UserEditPage, 2) and: 'select Roles tab' - editPage.rolesTab.select() + page.rolesTab.select() then: '12 roles are listed and 1 is enabled' - editPage.rolesTab.totalEnabledRoles() == 1 - editPage.rolesTab.hasEnabledRoles(['ROLE_USER']) - editPage.rolesTab.totalRoles() == 12 + with(page.rolesTab) { + totalEnabledRoles() == 1 + hasEnabledRoles(['ROLE_USER']) + totalRoles() == 12 + } } } From e2286671dafd93a1376bd992c4ff2815233fa84c Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Sun, 19 Apr 2026 08:27:32 +0200 Subject: [PATCH 09/29] test: update faulty test --- .../groovy/spec/RegistrationCodeSpec.groovy | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy index 7082a38aa..e3c8004ef 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy @@ -93,16 +93,15 @@ class RegistrationCodeSpec extends AbstractSecuritySpec { token.text = 'new_token' submit() } - page = at(RegistrationCodeEditPage) then: - with(page) { - username.text == 'new_user' - token.text == 'new_token' - } + at(RegistrationCodeEditPage) - when: - page = to(RegistrationCodeSearchPage, 4) + when: 'visit so the edit page can be verified properly after submit' + to(RegistrationCodeSearchPage) + + and: + page = to(RegistrationCodeEditPage, 4) then: with(page) { From 597bc093d741a721729edc40964b969f41448b2f Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Sun, 19 Apr 2026 08:55:50 +0200 Subject: [PATCH 10/29] test: update flaky tests --- .../groovy/test/User1FunctionalSpec.groovy | 3 +- .../groovy/spec/UserSpec.groovy | 36 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy index c62b204ad..6a7614711 100644 --- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy +++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy @@ -128,12 +128,13 @@ class User1FunctionalSpec extends AbstractSecuritySpec { when: page.grantPermission('user2', READ) + page = at(ShowReportPage) then: page.message == "Permission $READ.mask granted on Report 12 to user2" when: - to(ReportGrantPage, 12) + page = to(ReportGrantPage, 12) then: page.heading == 'Grant permission for report12' diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy index ed708d771..f134d0fb6 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/UserSpec.groovy @@ -38,9 +38,12 @@ class UserSpec extends AbstractSecuritySpec { when: page.submit() + page = at(UserSearchPage) then: - page.assertResults(1, 10, 22) + waitFor { // wait for the page to re-load and display results + page.assertResults(1, 10, 22) + } } void testFindByUsername() { @@ -49,9 +52,13 @@ class UserSpec extends AbstractSecuritySpec { username.text = 'foo' submit() } + page = at(UserSearchPage) + then: - page.assertResults(1, 3, 3) + waitFor { // wait for the page to re-load and display results + page.assertResults(1, 3, 3) + } with(pageSource) { contains('foon_2') contains('foolkiller') @@ -70,7 +77,9 @@ class UserSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertResults(1, 1, 1) + waitFor { // wait for the page to re-load and display results} + page.assertResults(1, 1, 1) + } pageSource.contains('billy9494') } @@ -85,7 +94,9 @@ class UserSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertResults(1, 3, 3) + waitFor { // wait for the page to re-load and display results + page.assertResults(1, 3, 3) + } with(pageSource) { contains('maryrose') contains('ratuig') @@ -104,7 +115,9 @@ class UserSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertResults(1, 3, 3) + waitFor { // wait for the page to re-load and display results} + page.assertResults(1, 3, 3) + } with(pageSource) { contains('aaaaaasd') contains('achen') @@ -123,7 +136,9 @@ class UserSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertResults(1, 3, 3) + waitFor { // wait for the page to re-load and display results + page.assertResults(1, 3, 3) + } with(pageSource) { contains('hhheeeaaatt') contains('mscanio') @@ -141,9 +156,12 @@ class UserSpec extends AbstractSecuritySpec { username = newUsername submit() } + page = at(UserSearchPage) then: - page.assertNoResults() + waitFor { // wait for the page to re-load and display results + page.assertNoResults() + } // create when: @@ -199,6 +217,8 @@ class UserSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertNoResults() + waitFor { // wait for the page to re-load and display results} + page.assertNoResults() + } } } From 61e52cb01fb858c18ceb8b68b386c758c3a744b1 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Sun, 19 Apr 2026 09:38:29 +0200 Subject: [PATCH 11/29] test: update and cleanup flaky tests --- .../groovy/specs/DisableSpec.groovy | 101 ++--- .../groovy/specs/MiscSpec.groovy | 428 ++++++++++-------- 2 files changed, 280 insertions(+), 249 deletions(-) diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy index 1aed7a120..be3ebd8a1 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy @@ -16,13 +16,12 @@ * specific language governing permissions and limitations * under the License. */ - package specs -import grails.testing.mixin.integration.Integration import pages.IndexPage import spock.lang.IgnoreIf +import grails.testing.mixin.integration.Integration @Integration @IgnoreIf({ System.getProperty('TESTCONFIG') != 'misc' }) @@ -30,173 +29,173 @@ class DisableSpec extends AbstractHyphenatedSecuritySpec { void 'lock account'() { given: - String username = 'admin' + def username = 'admin' when: - login username + login(username) then: - at IndexPage + at(IndexPage) when: - go 'secure-annotated' + go('secure-annotated') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() then: - 'false' == getUserProperty(username, 'accountLocked') + getUserProperty(username, 'accountLocked') == 'false' when: - setUserProperty username, 'accountLocked', true + setUserProperty(username, 'accountLocked', true) then: - 'true' == getUserProperty(username, 'accountLocked') + getUserProperty(username, 'accountLocked') == 'true' when: - login username + login(username) then: - pageSource.contains('accountLocked') + waitFor { pageSource.contains('accountLocked') } // reset when: - setUserProperty username, 'accountLocked', false + setUserProperty(username, 'accountLocked', false) then: - 'false' == getUserProperty(username, 'accountLocked') + getUserProperty(username, 'accountLocked') == 'false' } void 'disable account'() { given: - String username = 'admin' + def username = 'admin' when: - login username + login(username) then: - at IndexPage + at(IndexPage) when: - go 'secure-annotated' + go('secure-annotated') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() then: - 'true' == getUserProperty(username, 'enabled') + getUserProperty(username, 'enabled') == 'true' when: - setUserProperty username, 'enabled', false + setUserProperty(username, 'enabled', false) then: - 'false' == getUserProperty(username, 'enabled') + getUserProperty(username, 'enabled') == 'false' when: - login username + login(username) then: - pageSource.contains('accountDisabled') + waitFor { pageSource.contains('accountDisabled') } // reset when: - setUserProperty username, 'enabled', true + setUserProperty(username, 'enabled', true) then: - 'true' == getUserProperty(username, 'enabled') + getUserProperty(username, 'enabled') == 'true' } void 'expire account'() { given: - String username = 'admin' + def username = 'admin' when: - login username + login(username) then: - at IndexPage + at(IndexPage) when: - go 'secure-annotated' + go('secure-annotated') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() then: - 'false' == getUserProperty(username, 'accountExpired') + getUserProperty(username, 'accountExpired') == 'false' when: - setUserProperty username, 'accountExpired', true + setUserProperty(username, 'accountExpired', true) then: - 'true' == getUserProperty(username, 'accountExpired') + getUserProperty(username, 'accountExpired') == 'true' when: - login username + login(username) then: - pageSource.contains('accountExpired') + waitFor { pageSource.contains('accountExpired') } // reset when: - setUserProperty username, 'accountExpired', false + setUserProperty(username, 'accountExpired', false) then: - 'false' == getUserProperty(username, 'accountExpired') + getUserProperty(username, 'accountExpired') == 'false' } void 'expire password'() { given: - String username = 'admin' + def username = 'admin' when: - login username + login(username) then: - at IndexPage + at(IndexPage) when: - go 'secure-annotated' + go('secure-annotated') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() then: - 'false' == getUserProperty(username, 'passwordExpired') + getUserProperty(username, 'passwordExpired') == 'false' when: - setUserProperty username, 'passwordExpired', true + setUserProperty(username, 'passwordExpired', true) then: - 'true' == getUserProperty(username, 'passwordExpired') + getUserProperty(username, 'passwordExpired') == 'true' when: - login username + login(username) then: - pageSource.contains('passwordExpired') + waitFor { pageSource.contains('passwordExpired') } // reset when: - setUserProperty username, 'passwordExpired', false + setUserProperty(username, 'passwordExpired', false) then: - 'false' == getUserProperty(username, 'passwordExpired') + getUserProperty(username, 'passwordExpired') == 'false' } private void setUserProperty(String user, String propertyName, value) { - go "hack/set-user-property?user=$user&$propertyName=$value" + go("hack/set-user-property?user=$user&$propertyName=$value") } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy index 899f7b8c2..18f8e07ef 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy @@ -16,28 +16,27 @@ * specific language governing permissions and limitations * under the License. */ - package specs import geb.module.TextInput -import grails.testing.mixin.integration.Integration -import org.springframework.security.crypto.password.PasswordEncoder import pages.IndexPage import spock.lang.IgnoreIf import spock.lang.Issue +import grails.testing.mixin.integration.Integration + @Integration @IgnoreIf({ System.getProperty('TESTCONFIG') != 'misc' }) class MiscSpec extends AbstractHyphenatedSecuritySpec { void 'salted password'() { given: - String username = 'testuser_books_and_movies' - PasswordEncoder passwordEncoder = createSha256Encoder() + def username = 'testuser_books_and_movies' + def passwordEncoder = createSha256Encoder() when: - String hashedPassword = getUserProperty(username, 'password') - String notSalted = passwordEncoder.encode('password') + def hashedPassword = getUserProperty(username, 'password') + def notSalted = passwordEncoder.encode('password') then: notSalted != hashedPassword @@ -45,44 +44,46 @@ class MiscSpec extends AbstractHyphenatedSecuritySpec { void 'switch user'() { when: - login 'admin' + login('admin') then: - at IndexPage + at(IndexPage) // verify logged in when: - go 'secure-annotated' + go('secure-annotated') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: - String auth = getSessionValue('SPRING_SECURITY_CONTEXT') + def auth = getSessionValue('SPRING_SECURITY_CONTEXT') then: - auth.contains 'Username=admin' - auth.contains 'Authenticated=true' - auth.contains 'ROLE_ADMIN' - auth.contains 'ROLE_USER' // new, added since inferred from role hierarchy - !auth.contains('ROLE_PREVIOUS_ADMINISTRATOR') + with(auth) { + contains('Username=admin') + contains('Authenticated=true') + contains('ROLE_ADMIN') + contains('ROLE_USER') // new, added since inferred from role hierarchy + !contains('ROLE_PREVIOUS_ADMINISTRATOR') + } // switch via GET when: - go 'login/impersonate?username=testuser' + go('login/impersonate?username=testuser') then: - pageSource.contains('Error 404 Page Not Found') + waitFor { pageSource.contains('Error 404 Page Not Found') } // switch via POST when: - go 'misc-test/test' + go('misc-test/test') def input = $("#username").module(TextInput) input.text = 'testuser' $("#switchUserFormSubmitButton").click() then: - pageSource.contains('Available Controllers:') + waitFor { pageSource.contains('Available Controllers:') } // verify logged in as testuser @@ -90,370 +91,401 @@ class MiscSpec extends AbstractHyphenatedSecuritySpec { auth = getSessionValue('SPRING_SECURITY_CONTEXT') then: - auth.contains 'Username=testuser' - auth.contains 'Authenticated=true' - auth.contains 'ROLE_USER' - auth.contains 'ROLE_PREVIOUS_ADMINISTRATOR' + with(auth) { + contains('Username=testuser') + contains('Authenticated=true') + contains('ROLE_USER') + contains('ROLE_PREVIOUS_ADMINISTRATOR') + } when: - go 'secure-annotated/user-action' + go('secure-annotated/user-action') then: - pageSource.contains('you have ROLE_USER') + waitFor { pageSource.contains('you have ROLE_USER') } // verify not logged in as admin when: - go 'secure-annotated/admin-either' + go('secure-annotated/admin-either') then: - pageSource.contains('Sorry, you\'re not authorized to view this page.') + waitFor { pageSource.contains('Sorry, you\'re not authorized to view this page.') } // switch back via GET when: - go 'logout/impersonate' + go('logout/impersonate') then: - pageSource.contains('Error 404 Page Not Found') + waitFor { pageSource.contains('Error 404 Page Not Found') } // switch via POST when: - go 'misc-test/test' + go('misc-test/test') $("#exitUserFormSubmitButton").click() then: - pageSource.contains('Available Controllers:') + waitFor { pageSource.contains('Available Controllers:') } // verify logged in as admin when: - go 'secure-annotated/admin-either' + go('secure-annotated/admin-either') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: auth = getSessionValue('SPRING_SECURITY_CONTEXT') then: - auth.contains 'Username=admin' - auth.contains 'Authenticated=true' - auth.contains 'ROLE_ADMIN' - auth.contains 'ROLE_USER' - !auth.contains('ROLE_PREVIOUS_ADMINISTRATOR') + with(auth) { + contains('Username=admin') + contains('Authenticated=true') + contains('ROLE_ADMIN') + contains('ROLE_USER') + !contains('ROLE_PREVIOUS_ADMINISTRATOR') + } } void 'hierarchical roles'() { when: - login 'admin' + login('admin') then: - at IndexPage + at(IndexPage) // verify logged in when: - go 'secure-annotated' + go('secure-annotated') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: - String auth = getSessionValue('SPRING_SECURITY_CONTEXT') + def auth = getSessionValue('SPRING_SECURITY_CONTEXT') then: - auth.contains 'Authenticated=true' - auth.contains 'ROLE_USER' + with(auth) { + contains('Authenticated=true') + contains('ROLE_USER') + } // now get an action that's ROLE_USER only when: - go 'secure-annotated/user-action' + go('secure-annotated/user-action') then: - pageSource.contains('you have ROLE_USER') + waitFor { pageSource.contains('you have ROLE_USER') } } void 'taglibs unauthenticated'() { when: - go 'misc-test/test' - - then: - !pageSource.contains('user and admin') - !pageSource.contains('user and admin and foo') - pageSource.contains('not user and not admin') - !pageSource.contains('user or admin') - pageSource.contains('accountNonExpired: "not logged in"') - pageSource.contains('id: "not logged in"') - pageSource.contains('Username is ""') - !pageSource.contains('logged in true') - pageSource.contains('logged in false') - !pageSource.contains('switched true') - pageSource.contains('switched false') - pageSource.contains('switched original username ""') - - !pageSource.contains('access with role user: true') - !pageSource.contains('access with role admin: true') - pageSource.contains('access with role user: false') - pageSource.contains('access with role admin: false') - - pageSource.contains('Can access /login/auth') - !pageSource.contains('Can access /secure-annotated') - !pageSource.contains('Cannot access /login/auth') - pageSource.contains('Cannot access /secure-annotated') - - pageSource.contains('anonymous access: true') - pageSource.contains('Can access /misc-test/test') - !pageSource.contains('anonymous access: false') - !pageSource.contains('Cannot access /misc-test/test') + go('misc-test/test') + + then: + with(pageSource) { + !contains('user and admin') + !contains('user and admin and foo') + contains('not user and not admin') + !contains('user or admin') + contains('accountNonExpired: "not logged in"') + contains('id: "not logged in"') + contains('Username is ""') + !contains('logged in true') + contains('logged in false') + !contains('switched true') + contains('switched false') + contains('switched original username ""') + + !contains('access with role user: true') + !contains('access with role admin: true') + contains('access with role user: false') + contains('access with role admin: false') + + contains('Can access /login/auth') + !contains('Can access /secure-annotated') + !contains('Cannot access /login/auth') + contains('Cannot access /secure-annotated') + + contains('anonymous access: true') + contains('Can access /misc-test/test') + !contains('anonymous access: false') + !contains('Cannot access /misc-test/test') + } } void 'taglibs user'() { when: - login 'testuser' + login('testuser') then: - at IndexPage + at(IndexPage) when: - go 'misc-test/test' + go('misc-test/test') then: - !pageSource.contains('user and admin') - !pageSource.contains('user and admin and foo') - !pageSource.contains('not user and not admin') - pageSource.contains('user or admin') - pageSource.contains('accountNonExpired: "true"') - !pageSource.contains('id: "not logged in"') // can't test on exact id, don't know what it is) - pageSource.contains('Username is "testuser"') - pageSource.contains('logged in true') - !pageSource.contains('logged in false') - !pageSource.contains('switched true') - pageSource.contains('switched false') - pageSource.contains('switched original username ""') + with(pageSource) { + !contains('user and admin') + !contains('user and admin and foo') + !contains('not user and not admin') + contains('user or admin') + contains('accountNonExpired: "true"') + !contains('id: "not logged in"') // can't test on exact id, don't know what it is) + contains('Username is "testuser"') + contains('logged in true') + !contains('logged in false') + !contains('switched true') + contains('switched false') + contains('switched original username ""') - pageSource.contains('access with role user: true') - !pageSource.contains('access with role admin: true') - !pageSource.contains('access with role user: false') - pageSource.contains('access with role admin: false') + contains('access with role user: true') + !contains('access with role admin: true') + !contains('access with role user: false') + contains('access with role admin: false') - pageSource.contains('Can access /login/auth') - !pageSource.contains('Can access /secure-annotated') - !pageSource.contains('Cannot access /login/auth') - pageSource.contains('Cannot access /secure-annotated') + contains('Can access /login/auth') + !contains('Can access /secure-annotated') + !contains('Cannot access /login/auth') + contains('Cannot access /secure-annotated') - pageSource.contains('anonymous access: false') - pageSource.contains('Can access /misc-test/test') - !pageSource.contains('anonymous access: true') + contains('anonymous access: false') + contains('Can access /misc-test/test') + !contains('anonymous access: true') + } } void 'taglibs admin'() { when: - login 'admin' + login('admin') then: - at IndexPage + at(IndexPage) when: - go 'misc-test/test' + go('misc-test/test') then: - pageSource.contains('user and admin') - !pageSource.contains('user and admin and foo') - !pageSource.contains('not user and not admin') - pageSource.contains('user or admin') - pageSource.contains('accountNonExpired: "true"') - !pageSource.contains('id: "not logged in"') // can't test on exact id, don't know what it is) - pageSource.contains('Username is "admin"') + with(pageSource) { + contains('user and admin') + !contains('user and admin and foo') + !contains('not user and not admin') + contains('user or admin') + contains('accountNonExpired: "true"') + !contains('id: "not logged in"') // can't test on exact id, don't know what it is) + contains('Username is "admin"') - pageSource.contains('logged in true') - !pageSource.contains('logged in false') - !pageSource.contains('switched true') - pageSource.contains('switched false') - pageSource.contains('switched original username ""') + contains('logged in true') + !contains('logged in false') + !contains('switched true') + contains('switched false') + contains('switched original username ""') - pageSource.contains('access with role user: true') - pageSource.contains('access with role admin: true') - !pageSource.contains('access with role user: false') - !pageSource.contains('access with role admin: false') + contains('access with role user: true') + contains('access with role admin: true') + !contains('access with role user: false') + !contains('access with role admin: false') - pageSource.contains('Can access /login/auth') - pageSource.contains('Can access /secure-annotated') - !pageSource.contains('Cannot access /login/auth') - !pageSource.contains('Cannot access /secure-annotated') + contains('Can access /login/auth') + contains('Can access /secure-annotated') + !contains('Cannot access /login/auth') + !contains('Cannot access /secure-annotated') - pageSource.contains('anonymous access: false') - pageSource.contains('Can access /misc-test/test') - !pageSource.contains('anonymous access: true') - !pageSource.contains('Cannot access /misc-test/test') + contains('anonymous access: false') + contains('Can access /misc-test/test') + !contains('anonymous access: true') + !contains('Cannot access /misc-test/test') + } } void 'controller methods unauthenticated'() { when: - go 'misc-test/test-controller-methods' + go('misc-test/test-controller-methods') then: - pageSource.contains('getPrincipal: org.springframework.security.core.userdetails.User') - pageSource.contains('Username=__grails.anonymous.user__') - pageSource.contains('Granted Authorities=[ROLE_ANONYMOUS]') - pageSource.contains('isLoggedIn: false') - pageSource.contains('loggedIn: false') - pageSource.contains('getAuthenticatedUser: null') - pageSource.contains('authenticatedUser: null') + with(pageSource) { + contains('getPrincipal: org.springframework.security.core.userdetails.User') + contains('Username=__grails.anonymous.user__') + contains('Granted Authorities=[ROLE_ANONYMOUS]') + contains('isLoggedIn: false') + contains('loggedIn: false') + contains('getAuthenticatedUser: null') + contains('authenticatedUser: null') + } } void 'controller methods authenticated'() { when: - login 'admin' + login('admin') then: - at IndexPage + at(IndexPage) when: - go 'misc-test/test-controller-methods' + go('misc-test/test-controller-methods') then: - pageSource.contains('getPrincipal: grails.plugin.springsecurity.userdetails.GrailsUser') - pageSource.contains('principal: grails.plugin.springsecurity.userdetails.GrailsUser') - pageSource.contains('Username=admin') - pageSource.contains('isLoggedIn: true') - pageSource.contains('loggedIn: true') - pageSource.contains('getAuthenticatedUser: TestUser(username:admin)') - pageSource.contains('authenticatedUser: TestUser(username:admin)') + with(pageSource) { + contains('getPrincipal: grails.plugin.springsecurity.userdetails.GrailsUser') + contains('principal: grails.plugin.springsecurity.userdetails.GrailsUser') + contains('Username=admin') + contains('isLoggedIn: true') + contains('loggedIn: true') + contains('getAuthenticatedUser: TestUser(username:admin)') + contains('authenticatedUser: TestUser(username:admin)') + } } void 'test hyphenated'() { when: - go 'foo-bar' + go('foo-bar') then: - pageSource.contains('Please Login') + waitFor { pageSource.contains('Please Login') } when: - go 'foo-bar/index' + to(IndexPage) + + and: + go('foo-bar/index') then: - pageSource.contains('Please Login') + waitFor { pageSource.contains('Please Login') } when: - go 'foo-bar/bar-foo' + to(IndexPage) + + and: + go('foo-bar/bar-foo') then: - pageSource.contains('Please Login') + waitFor { pageSource.contains('Please Login') } when: logout() - login 'admin' then: - at IndexPage + at(IndexPage) + + when: + login('admin') + + then: + at(IndexPage) when: - go 'foo-bar' + go('foo-bar') then: - pageSource.contains('INDEX') + waitFor { pageSource.contains('INDEX') } when: - go 'foo-bar/index' + go('foo-bar/index') then: - pageSource.contains('INDEX') + waitFor { pageSource.contains('INDEX') } when: - go 'foo-bar/bar-foo' + go('foo-bar/bar-foo') then: - pageSource.contains('barFoo') + waitFor { pageSource.contains('barFoo') } } @Issue('https://github.com/apache/grails-spring-security/issues/414') void 'test Servlet API methods unauthenticated'() { when: - go 'misc-test/test-servlet-api-methods' + go('misc-test/test-servlet-api-methods') then: - pageSource.contains('request.getUserPrincipal(): null') - pageSource.contains('request.userPrincipal: null') - pageSource.contains('request.isUserInRole(\'ROLE_ADMIN\'): false') - pageSource.contains('request.isUserInRole(\'ROLE_FOO\'): false') - pageSource.contains('request.getRemoteUser(): null') - pageSource.contains('request.remoteUser: null') + with(pageSource) { + contains('request.getUserPrincipal(): null') + contains('request.userPrincipal: null') + contains('request.isUserInRole(\'ROLE_ADMIN\'): false') + contains('request.isUserInRole(\'ROLE_FOO\'): false') + contains('request.getRemoteUser(): null') + contains('request.remoteUser: null') + } } @Issue('https://github.com/apache/grails-spring-security/issues/414') void 'test Servlet API methods authenticated'() { when: - login 'admin' + login('admin') then: - at IndexPage + at(IndexPage) when: - go 'misc-test/test-servlet-api-methods' + go('misc-test/test-servlet-api-methods') then: - pageSource.contains('request.getUserPrincipal(): UsernamePasswordAuthenticationToken') - pageSource.contains('request.userPrincipal: UsernamePasswordAuthenticationToken') - pageSource.contains('request.isUserInRole(\'ROLE_ADMIN\'): true') - pageSource.contains('request.isUserInRole(\'ROLE_FOO\'): false') - pageSource.contains('request.getRemoteUser(): admin') - pageSource.contains('request.remoteUser: admin') + with(pageSource) { + contains('request.getUserPrincipal(): UsernamePasswordAuthenticationToken') + contains('request.userPrincipal: UsernamePasswordAuthenticationToken') + contains('request.isUserInRole(\'ROLE_ADMIN\'): true') + contains('request.isUserInRole(\'ROLE_FOO\'): false') + contains('request.getRemoteUser(): admin') + contains('request.remoteUser: admin') + } } @Issue('https://github.com/apache/grails-spring-security/issues/403') void 'test controller with annotated index action, unauthenticated'() { when: - go 'index-annotated' + go('index-annotated') then: - pageSource.contains('Please Login') + waitFor { pageSource.contains('Please Login') } when: - go 'index-annotated/' + go('index-annotated/') then: - pageSource.contains('Please Login') + waitFor { pageSource.contains('Please Login') } when: - go 'index-annotated/index' + go('index-annotated/index') then: - pageSource.contains('Please Login') + waitFor { pageSource.contains('Please Login') } when: - go 'index-annotated/show' + go('index-annotated/show') then: - pageSource.contains('Please Login') + waitFor { pageSource.contains('Please Login') } } @Issue('https://github.com/apache/grails-spring-security/issues/403') void 'test controller with annotated index action, authenticated'() { when: - login 'admin' + login('admin') then: - at IndexPage + at(IndexPage) when: - go 'index-annotated' + go('index-annotated') then: - pageSource.contains('index action, principal: ') + waitFor { pageSource.contains('index action, principal: ') } when: - go 'index-annotated/' + go('index-annotated/') then: - pageSource.contains('index action, principal: ') + waitFor { pageSource.contains('index action, principal: ') } when: - go 'index-annotated/index' + go('index-annotated/index') then: - pageSource.contains('index action, principal: ') + waitFor { pageSource.contains('index action, principal: ') } when: - go 'index-annotated/show' + go('index-annotated/show') then: - pageSource.contains('Sorry, you\'re not authorized to view this page.') + waitFor { pageSource.contains('Sorry, you\'re not authorized to view this page.') } } } From 475693604739f16d5cc6036bd5c19e792c7ef472 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Sun, 19 Apr 2026 09:59:36 +0200 Subject: [PATCH 12/29] test: update and cleanup flaky tests --- .../groovy/specs/BasicAuthSecuritySpec.groovy | 33 ++++--- .../groovy/spec/RequestmapSpec.groovy | 90 +++++++++++-------- .../groovy/module/RolesTab.groovy | 5 +- .../groovy/page/user/UserEditPage.groovy | 2 +- .../groovy/spec/UserSimpleSpec.groovy | 3 +- 5 files changed, 77 insertions(+), 56 deletions(-) diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy index 98f1c2e8b..fd66a51c6 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy @@ -16,12 +16,8 @@ * specific language governing permissions and limitations * under the License. */ - package specs -import spock.lang.Stepwise - -import grails.testing.mixin.integration.Integration import pages.LoginPage import pages.role.CreateRolePage import pages.role.ListRolePage @@ -30,6 +26,9 @@ import pages.user.CreateUserPage import pages.user.ListUserPage import pages.user.ShowUserPage import spock.lang.IgnoreIf +import spock.lang.Stepwise + +import grails.testing.mixin.integration.Integration @Integration @Stepwise @@ -224,7 +223,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec { login('admin1', 'password1') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() @@ -237,35 +236,35 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec { login('admin1', 'password1') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated', 'admin1', 'password1') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated/index', 'admin1', 'password1') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated/otherAction', 'admin1', 'password1') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated/admin2', 'admin1', 'password1') then: - pageSource.contains('Error 403 Forbidden') + waitFor { pageSource.contains('Error 403 Forbidden') } } void 'check allowed for admin2'() { @@ -282,7 +281,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec { login('admin2', 'password2') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() @@ -295,7 +294,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec { login('admin2', 'password2') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() @@ -308,35 +307,35 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec { login('admin2', 'password2') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated', 'admin2', 'password2') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated/index', 'admin2', 'password2') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated/otherAction', 'admin2', 'password2') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } when: logout() getWithAuth('secureClassAnnotated/admin2', 'admin2', 'password2') then: - pageSource.contains('you have ROLE_ADMIN') + waitFor { pageSource.contains('you have ROLE_ADMIN') } } protected void logout() { diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RequestmapSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RequestmapSpec.groovy index 35f4311c7..0be11377d 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RequestmapSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RequestmapSpec.groovy @@ -29,23 +29,27 @@ class RequestmapSpec extends AbstractSecuritySpec { void testFindAll() { when: - def searchPage = to(RequestmapSearchPage) + def page = to(RequestmapSearchPage) then: - searchPage.assertNotSearched() + page.assertNotSearched() when: - searchPage.submit() - searchPage = at(RequestmapSearchPage) + page.submit() + page = at(RequestmapSearchPage) then: - searchPage.assertResults(1, 3, 3) - pageSource.contains('/secure/**') - pageSource.contains('ROLE_ADMIN') - pageSource.contains('/j_spring_security_switch_user') - pageSource.contains('ROLE_RUN_AS') - pageSource.contains('/**') - pageSource.contains('permitAll') + waitFor { // wait for the page to re-load and display results + page.assertResults(1, 3, 3) + } + with(pageSource) { + contains('/secure/**') + contains('ROLE_ADMIN') + contains('/j_spring_security_switch_user') + contains('ROLE_RUN_AS') + contains('/**') + contains('permitAll') + } } void testFindByConfigAttribute() { @@ -54,12 +58,16 @@ class RequestmapSpec extends AbstractSecuritySpec { configAttribute = 'run' submit() } - def searchPage = at(RequestmapSearchPage) + def page = at(RequestmapSearchPage) then: - searchPage.assertResults(1, 1, 1) - pageSource.contains('/j_spring_security_switch_user') - pageSource.contains('ROLE_RUN_AS') + waitFor { // wait for the page to re-load and display results + page.assertResults(1, 1, 1) + } + with(pageSource) { + contains('/j_spring_security_switch_user') + contains('ROLE_RUN_AS') + } } void testFindByUrl() { @@ -68,12 +76,16 @@ class RequestmapSpec extends AbstractSecuritySpec { urlPattern = 'secure' submit() } - def searchPage = at(RequestmapSearchPage) + def page = at(RequestmapSearchPage) then: - searchPage.assertResults(1, 1, 1) - pageSource.contains('/secure/**') - pageSource.contains('ROLE_ADMIN') + waitFor { // wait for the page to re-load and display results + page.assertResults(1, 1, 1) + } + with(pageSource) { + contains('/secure/**') + contains('ROLE_ADMIN') + } } void testUniqueUrl() { @@ -83,15 +95,17 @@ class RequestmapSpec extends AbstractSecuritySpec { configAttribute = 'ROLE_FOO' submit() } + at(RequestmapCreatePage) then: - at(RequestmapCreatePage) - pageSource.contains('must be unique') + waitFor { // wait for the page to re-load and display validation errors + pageSource.contains('must be unique') + } } void testCreateAndEdit() { given: - String newPattern = "/foo/${UUID.randomUUID()}" + def newPattern = "/foo/${UUID.randomUUID()}" // make sure it doesn't exist when: @@ -99,10 +113,10 @@ class RequestmapSpec extends AbstractSecuritySpec { urlPattern = newPattern submit() } - def searchPage = at(RequestmapSearchPage) + def page = at(RequestmapSearchPage) then: - searchPage.assertNoResults() + waitFor { page.assertNoResults() } // create when: @@ -111,31 +125,35 @@ class RequestmapSpec extends AbstractSecuritySpec { configAttribute = 'ROLE_FOO' submit() } - def editPage = at(RequestmapEditPage) + page = at(RequestmapEditPage) then: - editPage.urlPattern.text == newPattern + page.urlPattern.text == newPattern // edit when: - editPage.urlPattern = "${newPattern}/new" - editPage.submit() - editPage = at(RequestmapEditPage) + page.urlPattern = "${newPattern}/new" + page.submit() + page = at(RequestmapEditPage) then: - editPage.urlPattern.text == "${newPattern}/new" + waitFor { // wait for the page to re-load and display updated values + page.urlPattern.text == "${newPattern}/new" + } // delete when: - editPage.delete() - searchPage = at(RequestmapSearchPage) + page.delete() + page = at(RequestmapSearchPage) and: - searchPage.urlPattern = "${newPattern}/new" - searchPage.submit() - searchPage = at(RequestmapSearchPage) + page.urlPattern = "${newPattern}/new" + page.submit() + page = at(RequestmapSearchPage) then: - searchPage.assertNoResults() + waitFor { // wait for the page to re-load and display results} + page.assertNoResults() + } } } diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/module/RolesTab.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/module/RolesTab.groovy index 437a9eb13..beedcc984 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/module/RolesTab.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/module/RolesTab.groovy @@ -28,7 +28,10 @@ import geb.navigator.Navigator class RolesTab extends Module { static content = { - tab { $('a', href: '#tab-roles') } + tab { + // Needs to be dynamic to ensure not becoming stale + $('a', href: '#tab-roles', dynamic: true) + } } void select() { diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy index fd1098223..fdcece961 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy @@ -40,6 +40,6 @@ class UserEditPage extends EditPage { accountExpired { $(name: 'accountExpired').module(Checkbox) } accountLocked { $(name: 'accountLocked').module(Checkbox) } passwordExpired { $(name: 'passwordExpired').module(Checkbox) } - rolesTab { module RolesTab } + rolesTab { module(RolesTab) } } } diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy index dd2466b76..30b4c8742 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy @@ -18,12 +18,13 @@ */ package spec -import grails.testing.mixin.integration.Integration import page.user.UserCreatePage import page.user.UserEditPage import page.user.UserSearchPage import spock.lang.Issue +import grails.testing.mixin.integration.Integration + @Integration class UserSimpleSpec extends AbstractSecuritySpec { From a99db080d33081b77df07a4b2a0a14dbf6bda231 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 20 Apr 2026 10:00:21 +0200 Subject: [PATCH 13/29] test(feedback): add back target page for button --- .../src/integration-test/groovy/pages/role/EditRolePage.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy index b5b1029ea..2d44172f4 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy @@ -25,6 +25,6 @@ class EditRolePage extends EditPage { static content = { authorityField { $('input', name: 'authority').module(TextInput) } - updateButton { $('input', value: 'Update') } + updateButton(to: ShowRolePage) { $('input', value: 'Update') } } } From e091bbaf3198a3caa0700d300ae48fb83451737a Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 20 Apr 2026 13:41:36 +0200 Subject: [PATCH 14/29] test: update flaky tests --- .../groovy/spec/RegistrationCodeSpec.groovy | 12 ++++-- .../groovy/page/user/UserEditPage.groovy | 1 + .../groovy/spec/UserSimpleSpec.groovy | 41 +++++++++++++++---- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy index e3c8004ef..953956815 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/RegistrationCodeSpec.groovy @@ -40,7 +40,9 @@ class RegistrationCodeSpec extends AbstractSecuritySpec { page = at(RegistrationCodeSearchPage) then: - page.assertResults(1, 10, 14) + waitFor { // Wait for the search results page to reload + page.assertResults(1, 10, 14) + } with(pageSource) { contains('registration_test_2') contains('0a154624f36d42e4aa68991a9477bd04') @@ -55,7 +57,9 @@ class RegistrationCodeSpec extends AbstractSecuritySpec { } then: - page.assertResults(1, 1, 1) + waitFor { // Wait for the search results page to reload + page.assertResults(1, 1, 1) + } with(pageSource) { contains('registration_test_1') contains('4a7f88afec3746f7aab2f5d0d8df6d8e') @@ -70,7 +74,9 @@ class RegistrationCodeSpec extends AbstractSecuritySpec { } then: - page.assertResults(1, 5, 5) + waitFor { // Wait for the search results page to reload + page.assertResults(1, 5, 5) + } with(pageSource) { contains('registration_test_3') contains('89f9bbc658b14808ae4c77c6e17e551a') diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy index fdcece961..5b3d937b1 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/page/user/UserEditPage.groovy @@ -35,6 +35,7 @@ class UserEditPage extends EditPage { } static content = { + userId { $('input', type: 'hidden', name: 'id', 0).value() } username { $('#username').module(TextInput) } enabled { $(name: 'enabled').module(Checkbox) } accountExpired { $(name: 'accountExpired').module(Checkbox) } diff --git a/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy b/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy index 30b4c8742..b2991dc8b 100644 --- a/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy +++ b/plugin-ui/examples/simple/src/integration-test/groovy/spec/UserSimpleSpec.groovy @@ -22,9 +22,11 @@ import page.user.UserCreatePage import page.user.UserEditPage import page.user.UserSearchPage import spock.lang.Issue +import spock.lang.Stepwise import grails.testing.mixin.integration.Integration +@Stepwise @Integration class UserSimpleSpec extends AbstractSecuritySpec { @@ -40,7 +42,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertResults(1, 10, 22) + waitFor { // Wait for the search results page to reload + page.assertResults(1, 10, 22) + } } void testFindByUsername() { @@ -52,7 +56,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { def page = at(UserSearchPage) then: - page.assertResults(1, 3, 3) + waitFor { // Wait for the search results page to reload + page.assertResults(1, 3, 3) + } with(pageSource) { contains('foon_2') contains('foolkiller') @@ -71,7 +77,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertResults(1, 1, 1) + waitFor { // Wait for the search results page to reload + page.assertResults(1, 1, 1) + } pageSource.contains('billy9494') } @@ -86,7 +94,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { searchPage = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) + waitFor { // Wait for the search results page to reload + searchPage.assertResults(1, 3, 3) + } with(pageSource) { contains('maryrose') contains('ratuig') @@ -105,7 +115,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { searchPage = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) + waitFor { // Wait for the search results page to reload + searchPage.assertResults(1, 3, 3) + } with(pageSource) { contains('aaaaaasd') contains('achen') @@ -124,7 +136,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { searchPage = at(UserSearchPage) then: - searchPage.assertResults(1, 3, 3) + waitFor { // Wait for the search results page to reload + searchPage.assertResults(1, 3, 3) + } pageSource.with { contains('hhheeeaaatt') contains('mscanio') @@ -144,7 +158,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { } then: - page.assertNoResults() + waitFor { // Wait for the search results page to reload + page.assertNoResults() + } // create when: @@ -177,6 +193,13 @@ class UserSimpleSpec extends AbstractSecuritySpec { submit() } page = at(UserEditPage) + def userId = page.userId + + and: 'visit other page so the edit page can be verified properly after submit' + to(UserSearchPage) + + and: + page = to(UserEditPage, userId) then: with(page) { @@ -201,7 +224,9 @@ class UserSimpleSpec extends AbstractSecuritySpec { page = at(UserSearchPage) then: - page.assertNoResults() + waitFor { // Wait for the search results page to reload + page.assertNoResults() + } } @Issue('https://github.com/grails-plugins/grails-spring-security-ui/issues/89') From ff83da6517ee39dde4c99f4b14a5bb063b42382a Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 21 Apr 2026 13:36:09 +0200 Subject: [PATCH 15/29] test: replace mn client with `testing-support-http-client` --- gradle.properties | 1 - .../examples/functional-test-app/build.gradle | 4 +- .../specs/CustomFilterRegistrationSpec.groovy | 23 +- .../groovy/specs/HttpClientSpec.groovy | 60 ----- .../specs/TestFormParamsControllerSpec.groovy | 245 ++++++++++-------- 5 files changed, 147 insertions(+), 186 deletions(-) delete mode 100644 plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/HttpClientSpec.groovy diff --git a/gradle.properties b/gradle.properties index c0607a7f3..7d6331fd6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,6 @@ gradleCryptoChecksumVersion=1.4.0 grailsRedisVersion=5.0.1 guavaVersion=33.5.0-jre mailVersion=5.0.2 -micronautVersion=4.10.7 nimbusVersion=10.5 pac4jVersion=6.2.2 ratVersion=0.8.1 diff --git a/plugin-core/examples/functional-test-app/build.gradle b/plugin-core/examples/functional-test-app/build.gradle index 11dc139b6..f3dac097a 100644 --- a/plugin-core/examples/functional-test-app/build.gradle +++ b/plugin-core/examples/functional-test-app/build.gradle @@ -44,11 +44,9 @@ dependencies { runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat' integrationTestImplementation testFixtures('org.apache.grails:grails-geb') - integrationTestImplementation "io.micronaut:micronaut-http-client:$micronautVersion" + integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client' integrationTestImplementation 'org.apache.grails:grails-testing-support-web' integrationTestImplementation 'org.spockframework:spock-core' - - integrationTestRuntimeOnly "io.micronaut:micronaut-jackson-databind:$micronautVersion" } apply { diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy index 10f0f4d25..51d2100e1 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy @@ -16,26 +16,25 @@ * specific language governing permissions and limitations * under the License. */ - package specs -import grails.testing.mixin.integration.Integration -import io.micronaut.http.HttpRequest -import io.micronaut.http.HttpResponse -import io.micronaut.http.HttpStatus import spock.lang.IgnoreIf import spock.lang.Issue +import spock.lang.Specification + +import grails.testing.mixin.integration.Integration +import org.apache.grails.testing.http.client.HttpClientSupport +@Integration @IgnoreIf({ System.getProperty('TESTCONFIG') != 'issue503' }) @Issue('https://github.com/apache/grails-spring-security/issues/503') -@Integration(applicationClass = functional.test.app.Application) -class CustomFilterRegistrationSpec extends HttpClientSpec { +class CustomFilterRegistrationSpec extends Specification implements HttpClientSupport { - void 'GET request to /assets/spinner.gif should not throw error because custom filter is excluded'() { - when: "A GET request to the assets directory is made" - HttpResponse response = client.exchange(HttpRequest.GET("/assets/spinner.gif")) + void 'GET request to spinner image asset should not throw error because custom filter is excluded'() { + when: 'A GET request to the assets directory is made' + def response = http('/assets/spinner.gif') - then: "the filter is not invoked because of the chainMap definition of filters: 'none' in application.groovy" - response.status == HttpStatus.OK + then: 'the filter is not invoked because of the chainMap definition of filters: "none" in application.groovy' + response.assertStatus(200) } } diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/HttpClientSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/HttpClientSpec.groovy deleted file mode 100644 index 1a924198a..000000000 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/HttpClientSpec.groovy +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 specs - -import io.micronaut.http.client.BlockingHttpClient -import io.micronaut.http.client.HttpClient -import spock.lang.Shared -import spock.lang.Specification - -abstract class HttpClientSpec extends Specification { - - @Shared HttpClient _httpClient - @Shared BlockingHttpClient _client - - HttpClient getHttpClient() { - if(!_httpClient) { - _httpClient = createHttpClient() - } - _httpClient - } - - BlockingHttpClient getClient() { - if(!_client) { - _client = getHttpClient().toBlocking() - } - _client - } - - HttpClient createHttpClient() { - String baseUrl = "http://localhost:$serverPort" - HttpClient.create(baseUrl.toURL()) - } - - def cleanupSpec() { - resetHttpClient() - } - - void resetHttpClient() { - _httpClient?.close() - _httpClient = null - _client = null - } -} diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy index 9af278c38..d6096abdc 100644 --- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy +++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy @@ -16,165 +16,190 @@ * specific language governing permissions and limitations * under the License. */ - package specs -import functional.test.app.Application -import grails.testing.mixin.integration.Integration -import io.micronaut.http.HttpRequest -import io.micronaut.http.HttpResponse -import io.micronaut.http.HttpStatus -import io.micronaut.http.uri.UriTemplate -import org.springframework.util.LinkedMultiValueMap -import org.springframework.util.MultiValueMap +import java.net.http.HttpRequest +import java.nio.charset.StandardCharsets + import spock.lang.IgnoreIf import spock.lang.Issue import spock.lang.Shared +import spock.lang.Specification + +import grails.testing.mixin.integration.Integration +import org.apache.grails.testing.http.client.HttpClientSupport +@Integration @IgnoreIf({ System.getProperty('TESTCONFIG') != 'putWithParams' }) @Issue('https://github.com/apache/grails-spring-security/issues/554') -@Integration(applicationClass = Application) -class TestFormParamsControllerSpec extends HttpClientSpec { +class TestFormParamsControllerSpec extends Specification implements HttpClientSupport { - @Shared String USERNAME = "Admin" - @Shared String PASSWORD = "myPassword" + @Shared String USERNAME = 'Admin' + @Shared String PASSWORD = 'myPassword' void 'PUT request with no parameters'() { - - when: "A PUT request with no parameters is made" - HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAll", "").contentType("application/x-www-form-urlencoded"), String) - - then: "the controller responds with the correct status and parameters are null" - response.status == HttpStatus.OK - response.body() == "username: null, password: null" + when: 'A PUT request with no parameters is made' + def response = httpPut( + '/testFormParams/permitAll', + '', + 'application/x-www-form-urlencoded' + ) + + then: 'the controller responds with the correct status and parameters are null' + response.assertEquals(200, 'username: null, password: null') } void 'PUT request with parameters in the URL'() { - when: "A PUT request with no parameters is made" - String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD]) - HttpResponse response = client.exchange(HttpRequest.PUT(expandUrl, "").contentType("application/x-www-form-urlencoded"), String) - - then: "the controller responds with the correct status and parameters are extracted" - response.status == HttpStatus.OK - response.body() == "username: ${USERNAME}, password: ${PASSWORD}" + when: 'A PUT request with parameters is made' + def response = httpPut( + urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD), + '', + 'application/x-www-form-urlencoded' + ) + + then: 'the controller responds with the correct status and parameters are extracted' + response.assertEquals(200, "username: $USERNAME, password: $PASSWORD") } void 'PUT request with parameters as x-www-form-urlencoded'() { - given: "a form with username and password params" - MultiValueMap form = new LinkedMultiValueMap() - form.add("username", USERNAME) - form.add("password", PASSWORD) - - when: "A PUT request with form params is made" - HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAll", form).contentType("application/x-www-form-urlencoded"), String) - - then: "the controller responds with the correct status and parameters are extracted" - response.status == HttpStatus.OK - response.body() == "username: ${USERNAME}, password: ${PASSWORD}" + when: 'A PUT request with form params is made' + def request = newHttpRequestWith('/testFormParams/permitAll') { + method('PUT', formBodyWith(username: USERNAME, password: PASSWORD)) + header('Content-Type', 'application/x-www-form-urlencoded') + } + def response = sendHttpRequest(request) + + then: 'the controller responds with the correct status and parameters are extracted' + response.assertEquals(200, "username: $USERNAME, password: $PASSWORD") } void 'PUT request with NULL Content-Type and parameters in the URL'() { - when: "A PUT request with no parameters is made" - String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD]) - HttpResponse response = client.exchange(HttpRequest.PUT(expandUrl, ""), String) - - then: "the controller responds with the correct status and parameters are extracted" - response.status == HttpStatus.OK - response.body() == "username: ${USERNAME}, password: ${PASSWORD}" + when: 'A PUT request with no parameters is made' + def url = urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD) + def request = newHttpRequestWith(url) { + method('PUT', HttpRequest.BodyPublishers.ofString('')) + } + def response = sendHttpRequest(request) + + then: 'the controller responds with the correct status and parameters are extracted' + response.assertEquals(200, "username: $USERNAME, password: $PASSWORD") } void 'PUT request with NULL Content-Type'() { - when: "A PUT request with NULL Content-Type is made" - HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAll", ""), String) + when: 'A PUT request with NULL Content-Type is made' + def request = newHttpRequestWith('/testFormParams/permitAll') { + method('PUT', HttpRequest.BodyPublishers.noBody()) + } + def response = sendHttpRequest(request) then: "the controller responds with the correct status and parameters are null" - response.status == HttpStatus.OK - response.body() == "username: null, password: null" + response.assertEquals(200, 'username: null, password: null') } void 'PATCH request with no parameters'() { - when: "A PATCH request with no parameters is made" - HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAll", "").contentType("application/x-www-form-urlencoded"), String) - - then: "the controller responds with the correct status and parameters are null" - response.status == HttpStatus.OK - response.body() == "username: null, password: null" + when: 'A PATCH request with no parameters is made' + def response = httpPatch( + '/testFormParams/permitAll', + '', + 'application/x-www-form-urlencoded' + ) + + then: 'the controller responds with the correct status and parameters are null' + response.assertEquals(200, 'username: null, password: null') } void 'PATCH request with parameters in the URL'() { when: - String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD]) - HttpResponse response = client.exchange(HttpRequest.PATCH(expandUrl, "").contentType("application/x-www-form-urlencoded"), String) - - then: "the controller responds with the correct status and parameters are extracted" - response.status == HttpStatus.OK - response.body() == "username: ${USERNAME}, password: ${PASSWORD}" + def response = httpPatch( + urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD), + '', + 'application/x-www-form-urlencoded' + ) + + then: 'the controller responds with the correct status and parameters are extracted' + response.assertEquals(200, "username: $USERNAME, password: $PASSWORD") } void 'PATCH request with parameters as x-www-form-urlencoded'() { - given: "a form with username and password params" - MultiValueMap form = new LinkedMultiValueMap() - form.add("username", USERNAME) - form.add("password", PASSWORD) - - when: "A PATCH request with form params is made" - HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAll", form - ).contentType("application/x-www-form-urlencoded"), String) - - then: "the controller responds with the correct status and parameters are extracted" - response.status == HttpStatus.OK - response.body() == "username: ${USERNAME}, password: ${PASSWORD}" + when: 'A PATCH request with form params is made' + def request = newHttpRequestWith('/testFormParams/permitAll') { + method('PATCH', formBodyWith(username: USERNAME, password: PASSWORD)) + header('Content-Type', 'application/x-www-form-urlencoded') + } + def response = sendHttpRequest(request) + + then: 'the controller responds with the correct status and parameters are extracted' + response.assertEquals(200, "username: $USERNAME, password: $PASSWORD") } void 'PUT request to secured endpoint with parameters as x-www-form-urlencoded'() { - given: "a form with username and password params" - MultiValueMap form = new LinkedMultiValueMap() - form.add("username", USERNAME) - form.add("password", PASSWORD) - - when: "A PUT request with form params is made to a secured endpoint" - HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAdmin", form - ).contentType("application/x-www-form-urlencoded"), String) - - then: "the request is not processed by the controller" - response.status == HttpStatus.OK // MN Client isn't exposing this is a 302 to login - response.body() != "username: ${USERNAME}, password: ${PASSWORD}" + when: 'A PUT request with form params is made to a secured endpoint' + def request = newHttpRequestWith('/testFormParams/permitAdmin') { + method('PUT', formBodyWith(username: USERNAME, password: PASSWORD)) + header('Content-Type', 'application/x-www-form-urlencoded') + } + def response = sendHttpRequest(request) + + then: 'the request is not processed by the controller' + response.assertNotEquals(200, "username: $USERNAME, password: $PASSWORD") // Client redirects to login page } void 'PATCH request to secured endpoint with parameters as x-www-form-urlencoded'() { - given: - MultiValueMap form = new LinkedMultiValueMap() - form.add("username", USERNAME) - form.add("password", PASSWORD) - - when: "A PATCH request with form params is made to a secured endpoint" - HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAdmin", form - ).contentType("application/x-www-form-urlencoded"), String) - - then: "the request is not processed by the controller" - response.status == HttpStatus.OK // MN Client isn't exposing this is a 302 to login - response.body() != "username: ${USERNAME}, password: ${PASSWORD}" + when: 'A PATCH request with form params is made to a secured endpoint' + def request = newHttpRequestWith('/testFormParams/permitAdmin') { + method('PATCH', formBodyWith(username: USERNAME, password: PASSWORD)) + header('Content-Type', 'application/x-www-form-urlencoded') + } + def response = sendHttpRequest(request) + + then: 'the request is not processed by the controller' + response.assertNotEquals(200, "username: $USERNAME, password: $PASSWORD") // Client redirects to login page } void 'PATCH request with NULL Content-Type and parameters in the URL'() { when: - String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD]) - HttpResponse response = client.exchange(HttpRequest.PATCH(expandUrl, "" - ).contentType("application/x-www-form-urlencoded"), String) - - then: "the controller responds with the correct status and parameters are extracted" - response.status == HttpStatus.OK - response.body() == "username: ${USERNAME}, password: ${PASSWORD}" + def url = urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD) + def request = newHttpRequestWith(url) { + method('PATCH', HttpRequest.BodyPublishers.noBody()) + } + def response = sendHttpRequest(request) + + then: 'the controller responds with the correct status and parameters are extracted' + response.assertEquals(200, "username: $USERNAME, password: $PASSWORD") } void 'PATCH request with NULL Content-Type'() { - when: "A PATCH request with NULL Content-Type is made" - HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAll", "" - ), String) + when: 'A PATCH request with NULL Content-Type is made' + def request = newHttpRequestWith('/testFormParams/permitAll') { + method('PATCH', HttpRequest.BodyPublishers.noBody()) + } + def response = sendHttpRequest(request) + + then: 'the controller responds with the correct status and parameters are null' + response.assertEquals(200, 'username: null, password: null') + } - then: "the controller responds with the correct status and parameters are null" - response.status == HttpStatus.OK - response.body() == "username: null, password: null" + static String urlWithParams(Map params, String url) { + if (!params) { + return url + } + def queryString = params.collect { key, value -> + "${encode(key)}=${encode(value)}" + }.join('&') + "$url${url.contains('?') ? '&' : '?'}$queryString" + } + + private static HttpRequest.BodyPublisher formBodyWith(Map form) { + HttpRequest.BodyPublishers.ofString(toFormUrlEncoded(form)) } + private static String toFormUrlEncoded(Map form) { + form.collect { key, value -> + "${encode(key)}=${encode(value)}" + }.join('&') + } + + private static String encode(String value) { + URLEncoder.encode(value, StandardCharsets.UTF_8) + } } From 0211950bc925fc0e8fb95b3e58d64738b8245dd4 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 21 Apr 2026 16:11:13 +0200 Subject: [PATCH 16/29] test: update flaky test --- .../groovy/spec/AclSidSpec.groovy | 74 +++++++++++-------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/plugin-ui/examples/extended/src/integration-test/groovy/spec/AclSidSpec.groovy b/plugin-ui/examples/extended/src/integration-test/groovy/spec/AclSidSpec.groovy index 68035ab91..a15cc7c8d 100644 --- a/plugin-ui/examples/extended/src/integration-test/groovy/spec/AclSidSpec.groovy +++ b/plugin-ui/examples/extended/src/integration-test/groovy/spec/AclSidSpec.groovy @@ -29,17 +29,19 @@ class AclSidSpec extends AbstractSecuritySpec { void testFindAll() { when: - def searchPage = to(AclSidSearchPage) + def page = to(AclSidSearchPage) then: - searchPage.assertNotSearched() + page.assertNotSearched() when: - searchPage.submit() - searchPage = at(AclSidSearchPage) + page.submit() + page = at(AclSidSearchPage) then: - searchPage.assertResults(1, 3, 3) + waitFor { // Wait for the search page to be reloaded + page.assertResults(1, 3, 3) + } } void testFindBySid() { @@ -47,12 +49,16 @@ class AclSidSpec extends AbstractSecuritySpec { to(AclSidSearchPage).with { search('user') } - def searchPage = at(AclSidSearchPage) + def page = at(AclSidSearchPage) then: - searchPage.assertResults(1, 2, 2) - pageSource.contains('user1') - pageSource.contains('user2') + waitFor { // Wait for the search page to be reloaded + page.assertResults(1, 2, 2) + } + with(pageSource) { + contains('user1') + contains('user2') + } } void testFindByPrincipal() { @@ -65,9 +71,11 @@ class AclSidSpec extends AbstractSecuritySpec { then: at(AclSidSearchPage) - pageSource.contains('user1') - pageSource.contains('user2') - pageSource.contains('admin') + waitFor { // Wait for the search page to be reloaded + pageSource.contains('user1') + pageSource.contains('user2') + pageSource.contains('admin') + } } void testUniqueName() { @@ -78,12 +86,14 @@ class AclSidSpec extends AbstractSecuritySpec { then: at(AclSidCreatePage) - pageSource.contains('must be unique') + waitFor { // Wait for the create page to be reloaded + pageSource.contains('must be unique') + } } void testCreateAndEdit() { given: - String newName = "newuser${UUID.randomUUID()}" + def newName = "newuser${UUID.randomUUID()}" // make sure it doesn't exist when: @@ -91,42 +101,48 @@ class AclSidSpec extends AbstractSecuritySpec { sid = newName submit() } - def searchPage = at(AclSidSearchPage) + def page = at(AclSidSearchPage) then: - searchPage.assertNoResults() + waitFor { // Wait for the search page to be reloaded + page.assertNoResults() + } // create when: to(AclSidCreatePage).tap { create(newName, true) } - def editPage = at(AclSidEditPage) + page = at(AclSidEditPage) then: - editPage.sid.text == newName - editPage.principal.checked + page.sid.text == newName + page.principal.checked // edit when: - editPage.sid = "${newName}_new" - editPage.submit() - editPage = at(AclSidEditPage) + page.sid = "${newName}_new" + page.submit() + page = at(AclSidEditPage) then: - editPage.sid.text == "${newName}_new" + waitFor { // Wait for the edit page to be reloaded + page.sid.text == "${newName}_new" + } // delete when: - editPage.delete() - searchPage = at(AclSidSearchPage) + page.delete() + page = at(AclSidSearchPage) and: - searchPage.sid = "${newName}_new" - searchPage.submit() - searchPage = at(AclSidSearchPage) + page.sid = "${newName}_new" + page.submit() + page = at(AclSidSearchPage) then: - searchPage.assertNoResults() + waitFor { // Wait for the search page to be reloaded + page.assertNoResults() + } } } From 0c2e328fec4ea629439711d8c9a61a85a23e4d6c Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 21 Apr 2026 16:13:35 +0200 Subject: [PATCH 17/29] fix: remove autoconfiguration excluder We do not need to exclude any autoconfigurations as spring boot 7 has extracted them to specific starter modules that we don't use. --- ...onfigurationExcluderIntegrationSpec.groovy | 61 ----- .../SecurityAutoConfigurationExcluder.groovy | 123 ---------- .../main/resources/META-INF/spring.factories | 20 -- ...curityAutoConfigurationExcluderSpec.groovy | 219 ------------------ 4 files changed, 423 deletions(-) delete mode 100644 plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy delete mode 100644 plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy delete mode 100644 plugin-core/plugin/src/main/resources/META-INF/spring.factories delete mode 100644 plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy 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 deleted file mode 100644 index c4fbc68d6..000000000 --- a/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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.context.ApplicationContext -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 - ApplicationContext applicationContext - - void "SecurityAutoConfigurationExcluder class is on the classpath"() { - expect: - Class.forName( - 'grails.plugin.springsecurity.SecurityAutoConfigurationExcluder' - ) - } - - void "no duplicate SecurityFilterChain beans from auto-configuration"() { - given: - def filterChainBeans = applicationContext - .getBeanNamesForType(SecurityFilterChain) - - expect: - filterChainBeans.length <= 1 - } - - void "only the plugin UserDetailsService is registered"() { - given: - def udsBeans = applicationContext - .getBeanNamesForType(UserDetailsService) - - expect: - udsBeans.length >= 1 - udsBeans.any { it.contains('userDetailsService') } - } -} 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 deleted file mode 100644 index df1077ad6..000000000 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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 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. - * - *

When the Grails Spring Security plugin is on the classpath, Spring Boot's - * security auto-configurations (e.g. {@code SecurityAutoConfiguration}, - * {@code SecurityFilterAutoConfiguration}) create duplicate - * {@code SecurityFilterChain} beans and other security infrastructure that - * conflicts with the plugin's own bean definitions in - * {@link SpringSecurityCoreGrailsPlugin#doWithSpring}.

- * - *

Previously, users had to manually exclude up to 7 auto-configuration classes - * in {@code application.yml}. This filter removes that requirement by - * automatically filtering them out during Spring Boot's auto-configuration - * discovery phase.

- * - *

To disable this filter and allow Spring Boot's security auto-configurations - * to run, set the following property in {@code application.yml}:

- * - *
- * grails:
- *   plugin:
- *     springsecurity:
- *       excludeSpringSecurityAutoConfiguration: false
- * 
- * - *

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 -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) - } - - /** - * Spring Boot security auto-configuration classes that conflict with the - * Grails Spring Security plugin. These are excluded unconditionally when the - * plugin is on the classpath. - * - *
    - *
  • {@code SecurityAutoConfiguration} — creates a default {@code SecurityFilterChain} - * that conflicts with the plugin's {@code FilterChainProxy}
  • - *
  • {@code SecurityFilterAutoConfiguration} — registers a - * {@code DelegatingFilterProxyRegistrationBean} that duplicates the plugin's - * {@code springSecurityFilterChainRegistrationBean}
  • - *
  • {@code UserDetailsServiceAutoConfiguration} — creates an in-memory - * {@code UserDetailsService} that conflicts with the plugin's - * {@code GormUserDetailsService}
  • - *
  • {@code OAuth2ClientAutoConfiguration} ({@code ...oauth2.client.servlet}) — - * conflicts when the plugin-oauth2 module manages OAuth2 configuration
  • - *
  • {@code OAuth2ClientAutoConfiguration} ({@code ...oauth2.client}) — - * non-servlet variant of the above; also conflicts with plugin-oauth2
  • - *
  • {@code OAuth2ResourceServerAutoConfiguration} — conflicts with the - * plugin's resource server security setup
  • - *
  • {@code ManagementWebSecurityAutoConfiguration} — Actuator security - * that conflicts when Actuator is on the classpath
  • - *
- */ - private static final Set EXCLUDED_AUTO_CONFIGURATIONS = [ - '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', - ].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/resources/META-INF/spring.factories b/plugin-core/plugin/src/main/resources/META-INF/spring.factories deleted file mode 100644 index b778c86de..000000000 --- a/plugin-core/plugin/src/main/resources/META-INF/spring.factories +++ /dev/null @@ -1,20 +0,0 @@ -# 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 deleted file mode 100644 index aaec28d4f..000000000 --- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy +++ /dev/null @@ -1,219 +0,0 @@ -/* - * 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 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.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 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', - ] - } - - def "match handles mixed array of included and excluded auto-configurations"() { - given: - def autoConfigs = [ - 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration', - 'org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration', - 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration', - 'org.springframework.boot.autoconfigure.security.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.autoconfigure.security.servlet.SecurityAutoConfiguration', - ] as String[] - - when: - def results = excluder.match(autoConfigs, null) - - then: 'still works correctly' - !results[0] - } - - def "getExcludedAutoConfigurations returns all 7 known conflicting classes"() { - when: - def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations - - then: - excluded.size() == 7 - excluded.contains('org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration') - excluded.contains('org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration') - excluded.contains('org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration') - excluded.contains('org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration') - excluded.contains('org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration') - excluded.contains('org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration') - excluded.contains('org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration') - } - - 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.autoconfigure.security.servlet.SecurityAutoConfiguration', - 'org.springframework.boot.autoconfigure.security.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 no property set"() { - given: - def env = Mock(Environment) - env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> true - excluder.environment = env - - and: - def autoConfigs = [ - 'org.springframework.boot.autoconfigure.security.servlet.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.autoconfigure.security.servlet.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') - } - } -} From af8385989d8b66b0c84113d525522ebe66ffedbe Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 21 Apr 2026 16:32:21 +0200 Subject: [PATCH 18/29] fix: simplify `DefaultRestAuthenticatorEventPublisher` construction Parent `DefaultAuthenticationEventPublisher` creates its own no-op publisher when constructed without arguments. --- .../DefaultRestAuthenticationEventPublisher.groovy | 13 ++----------- ...faultRestAuthenticationEventPublisherSpec.groovy | 9 --------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy b/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy index 42ec6c951..952842156 100644 --- a/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy +++ b/plugin-rest/spring-security-rest/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy @@ -30,23 +30,14 @@ import org.springframework.security.authentication.DefaultAuthenticationEventPub @CompileStatic class DefaultRestAuthenticationEventPublisher extends DefaultAuthenticationEventPublisher implements RestAuthenticationEventPublisher { - // Spring Security 7 no longer accepts a null ApplicationEventPublisher in the - // DefaultAuthenticationEventPublisher constructor, so use a no-op fallback until - // Spring injects the real publisher through the setter. - private static final ApplicationEventPublisher NO_OP_APPLICATION_EVENT_PUBLISHER = new ApplicationEventPublisher() { - @Override - void publishEvent(Object event) { - } - } - private ApplicationEventPublisher applicationEventPublisher DefaultRestAuthenticationEventPublisher() { - this(null) + super() } DefaultRestAuthenticationEventPublisher(ApplicationEventPublisher publisher) { - super(publisher ?: NO_OP_APPLICATION_EVENT_PUBLISHER) + super(publisher) this.applicationEventPublisher = publisher } diff --git a/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy b/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy index 68c52e9b9..343679a96 100644 --- a/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy +++ b/plugin-rest/spring-security-rest/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy @@ -83,15 +83,6 @@ class DefaultRestAuthenticationEventPublisherSpec extends Specification { 0 * eventPublisher.publishEvent(_) } - void "should allow null publisher in constructor and remain no-op for token events"() { - when: - new DefaultRestAuthenticationEventPublisher(null).publishTokenCreation(accessToken) - - then: - noExceptionThrown() - 0 * eventPublisher.publishEvent(_) - } - void "should not execute parent publishEvent when publisher is not set"() { when: new DefaultRestAuthenticationEventPublisher().publishAuthenticationSuccess(accessToken) From 3a140c341e7f1e59747075d6e9040240a8dd34c7 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 11:49:54 -0400 Subject: [PATCH 19/29] fix: re-add SecurityAutoConfigurationExcluder updated for Spring Boot 4 Restores the auto-configuration excluder removed in 0c2e328fe with the correct Spring Boot 4 class names. Spring Boot 4 split the security auto-configurations into the spring-boot-security and spring-boot-security-oauth2-client modules and re-packaged them under `org.springframework.boot.security.autoconfigure.*` and `org.springframework.boot.security.oauth2.client.autoconfigure.*`. A user who explicitly adds spring-boot-starter-security or spring-boot-starter-oauth2-client would otherwise re-introduce a parallel SecurityFilterChain stack alongside the Grails plugin's own filter chain. Addresses jdaugherty's review feedback on PR #1214 about the plugin's "centralized" security configuration not composing with Spring's split namespaces. The mitigation here is mutual exclusion of the two stacks rather than property bridging: the plugin owns the security config and the excluder prevents Boot's auto-config from doubling it up. Class names were verified against the META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports files in spring-boot-security-4.0.5.jar and spring-boot-security-oauth2-client-4.0.5.jar. Also updates SpringSecurityBeanFactoryPostProcessor.AUTOCONFIG_NAME to the SB4 location of SecurityFilterAutoConfiguration so the belt-and-suspenders bean cleanup keeps working. Updates README.md to provide both Grails 8 / SB4 and Grails 7 / SB3 manual fallback exclusion examples. Assisted-by: claude-code:claude-4.6-opus --- README.md | 27 +- ...onfigurationExcluderIntegrationSpec.groovy | 61 +++++ .../SecurityAutoConfigurationExcluder.groovy | 142 ++++++++++ ...ingSecurityBeanFactoryPostProcessor.groovy | 2 +- .../main/resources/META-INF/spring.factories | 20 ++ ...curityAutoConfigurationExcluderSpec.groovy | 242 ++++++++++++++++++ 6 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy create mode 100644 plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy create mode 100644 plugin-core/plugin/src/main/resources/META-INF/spring.factories create mode 100644 plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy diff --git a/README.md b/README.md index 3e696cf40..a4ab13ca7 100644 --- a/README.md +++ b/README.md @@ -63,17 +63,34 @@ 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: +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.autoconfigure.*`): + +```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 +``` + +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/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..c4fbc68d6 --- /dev/null +++ b/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy @@ -0,0 +1,61 @@ +/* + * 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.context.ApplicationContext +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 + ApplicationContext applicationContext + + void "SecurityAutoConfigurationExcluder class is on the classpath"() { + expect: + Class.forName( + 'grails.plugin.springsecurity.SecurityAutoConfigurationExcluder' + ) + } + + void "no duplicate SecurityFilterChain beans from auto-configuration"() { + given: + def filterChainBeans = applicationContext + .getBeanNamesForType(SecurityFilterChain) + + expect: + filterChainBeans.length <= 1 + } + + void "only the plugin UserDetailsService is registered"() { + given: + def udsBeans = applicationContext + .getBeanNamesForType(UserDetailsService) + + expect: + udsBeans.length >= 1 + udsBeans.any { it.contains('userDetailsService') } + } +} 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..f5fe37ba5 --- /dev/null +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy @@ -0,0 +1,142 @@ +/* + * 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 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. + * + *

When the Grails Spring Security plugin is on the classpath together with + * one of Spring Boot's split-out security modules (such as + * {@code spring-boot-security}, {@code spring-boot-starter-security} or + * {@code spring-boot-security-oauth2-client}), Spring Boot's security + * auto-configurations create duplicate {@code SecurityFilterChain} beans and + * other security infrastructure that conflicts with the plugin's own bean + * definitions in + * {@link SpringSecurityCoreGrailsPlugin#doWithSpring}.

+ * + *

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 + * re-introduce the conflicting servlet auto-configurations. This filter prevents + * that by excluding them during Spring Boot's auto-configuration discovery + * phase.

+ * + *

Previously, users had to manually exclude up to 7 auto-configuration classes + * in {@code application.yml}. This filter removes that requirement by + * automatically filtering them out during Spring Boot's auto-configuration + * discovery phase.

+ * + *

To disable this filter and allow Spring Boot's security auto-configurations + * to run, set the following property in {@code application.yml}:

+ * + *
+ * grails:
+ *   plugin:
+ *     springsecurity:
+ *       excludeSpringSecurityAutoConfiguration: false
+ * 
+ * + *

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 8.0.0 + * @see AutoConfigurationImportFilter + */ +@CompileStatic +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) + } + + /** + * Spring Boot 4 security auto-configuration classes that conflict with the + * Grails Spring Security plugin's servlet-based security stack. These are + * excluded unconditionally when the plugin is on the classpath. + * + *

Verified against the {@code AutoConfiguration.imports} files in the + * Spring Boot 4 {@code spring-boot-security} and + * {@code spring-boot-security-oauth2-client} modules.

+ * + *
    + *
  • {@code SecurityAutoConfiguration} - creates a default + * {@code SecurityFilterChain} that conflicts with the plugin's + * {@code FilterChainProxy}
  • + *
  • {@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
  • + *
+ */ + 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', + ].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/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..fe66f0d03 --- /dev/null +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy @@ -0,0 +1,242 @@ +/* + * 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 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', + ] + } + + @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', + ] + } + + 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 7 known conflicting classes"() { + when: + def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations + + then: + excluded.size() == 7 + 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') + } + + 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 no property set"() { + 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') + } + } +} From 73079f26274eaca8d4689625dd7e0e4896a38358 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 11:50:07 -0400 Subject: [PATCH 20/29] docs: address PR #1214 review feedback on documentation and attribution - Adds class-level Groovydoc to MutableRoleHierarchy explaining why the class exists (RoleHierarchyImpl became immutable in Spring Security 6 and only exposes a static factory) and how its setHierarchy contract works (per jdaugherty's request to document the class). - Updates spring-security-compat module pomDescription per matrei's agreed rephrasing: 'Compatibility classes that make Grails Spring Security 8 work with newer Spring Security versions, such as 7 and later.' - Adds an attribution comment to the AbstractSecurityInterceptor compat shim noting it is based on the class of the same name in Spring Security (removed in 7), matching the precedent set by WebExpressionVoter and FilterProcessUrlRequestMatcher in this codebase. Assisted-by: claude-code:claude-4.6-opus --- .../MutableRoleHierarchy.groovy | 22 +++++++++++++++++++ spring-security-compat/build.gradle | 2 +- .../AbstractSecurityInterceptor.groovy | 8 +++++++ 3 files changed, 31 insertions(+), 1 deletion(-) 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.:

+ * + *
+ * ROLE_ADMIN > ROLE_USER
+ * ROLE_USER > ROLE_GUEST
+ * 
+ * + *

Setting the property to {@code null} or an empty string clears the hierarchy.

+ */ @CompileStatic class MutableRoleHierarchy implements RoleHierarchy { 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 { From a871b56090dc4eae0e4c5c560aefe18349b743a4 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 11:50:21 -0400 Subject: [PATCH 21/29] style(test): use g:submitButton in testRole/edit and testRequestmap/edit GSPs Replaces the bare markup in the two outliers with to match the rest of the functional-test-app GSPs (create.gsp variants already use g:submitButton). The rendered HTML is equivalent (g:submitButton emits an ) so the Geb $('input', value: 'Update') page selectors in EditRolePage and EditRequestmapPage are unaffected. Per jdaugherty's PR #1214 review note about consistent submit-button markup. Assisted-by: claude-code:claude-4.6-opus --- .../grails-app/views/testRequestmap/edit.gsp | 2 +- .../functional-test-app/grails-app/views/testRole/edit.gsp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 @@
- +
From a031654a5e79b61de7d98f8f2c6ba0926a87ee5f Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 12:11:03 -0400 Subject: [PATCH 22/29] fix: extend SecurityAutoConfigurationExcluder for SB4 modules and harden contract Multi-agent post-implementation review identified gaps in the initial SB4 port. This commit: - Adds 4 missing SB4 servlet auto-configuration exclusions verified against actual jar contents (`META-INF/spring/org.springframework.boot. autoconfigure.AutoConfiguration.imports`): * spring-boot-security-oauth2-resource-server: OAuth2ResourceServerAutoConfiguration * spring-boot-security-saml2: Saml2RelyingPartyAutoConfiguration * spring-boot-security-oauth2-authorization-server: OAuth2AuthorizationServerAutoConfiguration, OAuth2AuthorizationServerJwtAutoConfiguration - Documents the configuration contract in both class-level Javadoc and README/installation.adoc: while the plugin is active, `grails.plugin.springsecurity.*` is the authoritative configuration source and `spring.security.*` is not bridged. This addresses jdaugherty's PR #1214 review concern about the two namespaces not being combined. - Adds a startup WARN log via @Slf4j when `excludeSpringSecurityAuto Configuration=false`, so users do not silently lose the plugin's security guarantees. - Restores @since 7.0.2 (the version the original excluder was first introduced in via PR #1205) instead of 8.0.0. - Retabs the new Groovy files to match repo convention (tabs, not spaces). - Adds @Unroll spec coverage for the 4 new exclusions and for the SB4 reactive variants which we intentionally let pass through (servlet plugin only). README.md and installation.adoc list all 11 manually-equivalent exclusions and call out the configuration contract and the WARN-on-disable behaviour. Assisted-by: claude-code:claude-4.6-opus --- README.md | 14 +- .../src/docs/introduction/installation.adoc | 8 +- .../SecurityAutoConfigurationExcluder.groovy | 223 +++++---- ...curityAutoConfigurationExcluderSpec.groovy | 447 ++++++++++-------- 4 files changed, 398 insertions(+), 294 deletions(-) diff --git a/README.md b/README.md index a4ab13ca7..6a4b2b2ea 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,11 @@ 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. + +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,9 +65,11 @@ grails: excludeSpringSecurityAutoConfiguration: false ``` +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.autoconfigure.*`): +For Grails 8 / Spring Boot 4 (security auto-configurations live under `org.springframework.boot.security.*`): ```yml spring: @@ -78,6 +82,10 @@ spring: - 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.*`): diff --git a/plugin-core/docs/src/docs/introduction/installation.adoc b/plugin-core/docs/src/docs/introduction/installation.adoc index 08cfdf21f..f491ab2c8 100644 --- a/plugin-core/docs/src/docs/introduction/installation.adoc +++ b/plugin-core/docs/src/docs/introduction/installation.adoc @@ -74,9 +74,11 @@ 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. + +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 +88,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/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy index f5fe37ba5..1a4f2e7be 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy @@ -19,6 +19,7 @@ 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 @@ -29,30 +30,46 @@ import org.springframework.core.env.Environment * Automatically excludes Spring Boot security auto-configuration classes that * conflict with the Grails Spring Security plugin. * - *

When the Grails Spring Security plugin is on the classpath together with - * one of Spring Boot's split-out security modules (such as - * {@code spring-boot-security}, {@code spring-boot-starter-security} or - * {@code spring-boot-security-oauth2-client}), Spring Boot's security - * auto-configurations create duplicate {@code SecurityFilterChain} beans and - * other security infrastructure that conflicts with the plugin's own bean - * definitions in - * {@link SpringSecurityCoreGrailsPlugin#doWithSpring}.

+ *

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.

+ * + *

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 - * re-introduce the conflicting servlet auto-configurations. This filter prevents - * that by excluding them during Spring Boot's auto-configuration discovery - * phase.

+ * 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.

* - *

Previously, users had to manually exclude up to 7 auto-configuration classes - * in {@code application.yml}. This filter removes that requirement by - * automatically filtering them out during Spring Boot's auto-configuration - * discovery phase.

+ *

Opt-out

* *

To disable this filter and allow Spring Boot's security auto-configurations - * to run, set the following property in {@code application.yml}:

+ * 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}:

* *
  * grails:
@@ -61,82 +78,126 @@ import org.springframework.core.env.Environment
  *       excludeSpringSecurityAutoConfiguration: false
  * 
* + *

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 8.0.0 + * @since 7.0.2 * @see AutoConfigurationImportFilter */ @CompileStatic +@Slf4j class SecurityAutoConfigurationExcluder implements AutoConfigurationImportFilter, EnvironmentAware { - static final String ENABLED_PROPERTY = 'grails.plugin.springsecurity.excludeSpringSecurityAutoConfiguration' + static final String ENABLED_PROPERTY = 'grails.plugin.springsecurity.excludeSpringSecurityAutoConfiguration' - private boolean enabled = true + private boolean enabled = true - @Override - void setEnvironment(Environment environment) { - this.enabled = environment.getProperty(ENABLED_PROPERTY, Boolean, 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 security auto-configuration classes that conflict with the - * Grails Spring Security plugin's servlet-based security stack. These are - * excluded unconditionally when the plugin is on the classpath. - * - *

Verified against the {@code AutoConfiguration.imports} files in the - * Spring Boot 4 {@code spring-boot-security} and - * {@code spring-boot-security-oauth2-client} modules.

- * - *
    - *
  • {@code SecurityAutoConfiguration} - creates a default - * {@code SecurityFilterChain} that conflicts with the plugin's - * {@code FilterChainProxy}
  • - *
  • {@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
  • - *
- */ - 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', - ].toSet().asImmutable() + /** + * 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[] - } + @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 - } + /** + * 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/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy index fe66f0d03..3a7298da4 100644 --- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy @@ -27,216 +27,247 @@ import org.springframework.core.env.Environment /** * Tests for {@link SecurityAutoConfigurationExcluder}. * - * Verifies that Spring Boot 4 security auto-configuration classes that conflict - * with the Grails Spring Security plugin are filtered out during the + * 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', - ] - } - - @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', - ] - } - - 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 7 known conflicting classes"() { - when: - def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations - - then: - excluded.size() == 7 - 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') - } - - 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 no property set"() { - 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') - } - } + @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') + } + } } From c2e684b2ffe03c8938184deee65ffef353a9d9ec Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 12:11:18 -0400 Subject: [PATCH 23/29] test: tighten SecurityAutoConfigurationExcluder integration assertions Multi-agent post-implementation review pointed out that the original integration assertions were too weak - `SecurityFilterChain` count `<= 1` and `UserDetailsService` count `>= 1` would silently pass even if Boot security auto-configurations sneaked through and registered their own beans. This commit replaces the count-based assertions with concrete checks: - For every excluded auto-configuration class name (the 11-entry list in SecurityAutoConfigurationExcluder.EXCLUDED_AUTO_CONFIGURATIONS), assert that `applicationContext` does not contain a bean definition for that class. - Assert that no SecurityFilterChain bean has a class name starting with `org.springframework.boot.security.` (i.e. came from Boot's auto-config). - Assert that no UserDetailsService bean has a class name starting with `org.springframework.boot.security.`, and that Boot's `inMemoryUserDetailsManager` bean name in particular is absent. These are still trivial-pass assertions while the integration-test-app does not include any spring-boot-security* module on its classpath; the follow-up to add a Boot security starter to that test app and exercise the real exclusion path is left as a separate task because it touches the example app's runtime dependency graph. Also retabs to match repo convention (tabs, not spaces). Assisted-by: claude-code:claude-4.6-opus --- ...onfigurationExcluderIntegrationSpec.groovy | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) 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 index c4fbc68d6..ab2b10e2d 100644 --- 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 @@ -30,32 +30,47 @@ import grails.testing.mixin.integration.Integration @Integration class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification { - @Autowired - ApplicationContext applicationContext - - void "SecurityAutoConfigurationExcluder class is on the classpath"() { - expect: - Class.forName( - 'grails.plugin.springsecurity.SecurityAutoConfigurationExcluder' - ) - } - - void "no duplicate SecurityFilterChain beans from auto-configuration"() { - given: - def filterChainBeans = applicationContext - .getBeanNamesForType(SecurityFilterChain) - - expect: - filterChainBeans.length <= 1 - } - - void "only the plugin UserDetailsService is registered"() { - given: - def udsBeans = applicationContext - .getBeanNamesForType(UserDetailsService) - - expect: - udsBeans.length >= 1 - udsBeans.any { it.contains('userDetailsService') } - } + @Autowired + ApplicationContext 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: '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 -> + def def_ = applicationContext.getBeanFactory().getBeanDefinition(name) + !def_.beanClassName?.startsWith('org.springframework.boot.security.') + } + + and: 'none of the excluded auto-configuration class names are registered as beans' + SecurityAutoConfigurationExcluder.excludedAutoConfigurations.each { className -> + assert !applicationContext.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: '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 -> + def def_ = applicationContext.getBeanFactory().getBeanDefinition(name) + !def_.beanClassName?.startsWith('org.springframework.boot.security.') + } + + and: "Boot's in-memory UserDetailsService is not present" + !udsBeans.any { it == 'inMemoryUserDetailsManager' } + } } From 79b0e2b6e80eb60acea3e90123897591db1f1095 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 12:50:27 -0400 Subject: [PATCH 24/29] fix(build): add jline-reader compileOnly dependency to fix CI Spring Boot 4 / Grails 8 SNAPSHOT dependencies pulled in classes that reference `org.jline.reader.LineReader` (jline 3.x), but the plugin-core compile classpath only had `jline:jline:2.14.6` (jline 2.x, package `jline.console`). Groovy's static type checker fails to canonicalize types when it encounters a reference to a class whose required types are not on the classpath: General error during canonicalization: java.lang.NoClassDefFoundError: org.jline.reader.LineReader This regression was not caused by PR #1214 or this PR; the same failure reproduces when CI is re-run against the unmodified `grails8-sb4` HEAD (see workflow run 24935487319). Older successful runs were masked by Gradle build caching of pre-regression compile outputs. Adding `compileOnly 'org.jline:jline-reader:3.30.9'` makes the required types available to the compile classpath while keeping it out of the runtime classpath of plugin consumers (their own Groovy/Grails distribution provides jline at runtime). Verified locally: `./gradlew :core-plugin:check --max-workers=2 --continue` BUILD SUCCESSFUL with all unit tests passing. Assisted-by: claude-code:claude-4.6-opus --- plugin-core/plugin/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin-core/plugin/build.gradle b/plugin-core/plugin/build.gradle index 1776c90b7..8b2a3e956 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:3.30.9' // 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 From dbb62e8c40ff45438e3cad3d00899f64b7a795ab Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 13:16:42 -0400 Subject: [PATCH 25/29] fix(build): manage jline-reader version via gradle.properties + cover oauth2-plugin Follow-up to the previous CI fix to align with the project's dependency- management convention: - Defines `jlineReaderVersion=3.30.9` in `gradle.properties` alongside the other version properties (unboundidLdapSdkVersion, nimbusVersion, scribejavaVersion, etc.) since `org.jline:jline-reader` is not managed by any BOM on the compile classpath (grails-bom, spring-boot- bom, groovy-bom none provide it). - Updates `plugin-core/plugin/build.gradle` to use "org.jline:jline-reader:$jlineReaderVersion" instead of the hardcoded version. - Applies the same fix to `plugin-oauth2/plugin/build.gradle`, which hit the identical `org.jline.reader.LineReader` `NoClassDefFoundError` during `./gradlew check`. Verified locally: `./gradlew :core-plugin:compileGroovy :oauth2-plugin:compileGroovy --rerun-tasks` BUILD SUCCESSFUL. Assisted-by: claude-code:claude-4.6-opus --- gradle.properties | 1 + plugin-core/plugin/build.gradle | 2 +- plugin-oauth2/plugin/build.gradle | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) 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/plugin/build.gradle b/plugin-core/plugin/build.gradle index 8b2a3e956..38f427f1e 100644 --- a/plugin-core/plugin/build.gradle +++ b/plugin-core/plugin/build.gradle @@ -75,7 +75,7 @@ dependencies { compileOnly 'jakarta.servlet:jakarta.servlet-api' // Provided compileOnly 'jline:jline' // for shell commands - compileOnly 'org.jline:jline-reader:3.30.9' // Required for Groovy static type checking (referenced via Groovy 5.x groovysh by the grails-plugin gradle compiler) + 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-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) } From 1282c5cc9d0ac5a07cf374c67ca9e2bf0b9a2902 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 14:36:58 -0400 Subject: [PATCH 26/29] docs: document coexistence with component-based Spring Security configuration Spring Security 5.7 deprecated and Spring Security 6 removed WebSecurityConfigurerAdapter, replacing it with the component-based configuration model described in 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.*` namespace, but the behaviour when users define those component beans alongside the plugin was previously undocumented. This commit adds an explicit coexistence matrix to the SecurityAutoConfigurationExcluder Javadoc, README.md, and installation.adoc covering each pattern from the Spring blog: - `@Bean SecurityFilterChain` is created but never services requests; use `grails.plugin.springsecurity.filterChain.chainMap` / `filterNames` and `staticRules` instead. - `@Bean WebSecurityCustomizer` is a no-op (the plugin does not use Spring's `WebSecurity` builder); use `staticRules` with `permitAll` or `ipRestrictions` instead. - `@Bean AuthenticationManager` conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean; register custom `AuthenticationProvider` beans and add their names to `grails.plugin.springsecurity.providerNames`. - `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` coexists in the context but is unused; configure `grails.plugin.springsecurity.userLookup.userDomainClassName` or replace the `userDetailsService` bean. - 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. upgrading7x.adoc now links to the Spring blog post and references the new coexistence section, so users migrating from Spring Security 5.x WebSecurityConfigurerAdapter usage know what their custom beans will (and will not) do under this plugin. Verified locally: `./gradlew :docs:asciidoctor` BUILD SUCCESSFUL with the full coexistence table rendered in the generated HTML. Assisted-by: claude-code:claude-4.6-opus --- README.md | 14 +++++ docs/src/docs/upgrading/upgrading7x.adoc | 2 +- .../src/docs/introduction/installation.adoc | 31 ++++++++++ .../SecurityAutoConfigurationExcluder.groovy | 59 +++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6a4b2b2ea..c1b895e41 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,20 @@ The plugin automatically excludes Spring Boot's servlet security auto-configurat **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. The following table summarises how the plugin coexists with each component-based pattern when the auto-configuration excluder is enabled: + +| Spring component-based pattern | Behaviour with this plugin active | How to achieve the equivalent | +|---|---|---| +| `@Bean SecurityFilterChain` | Not added to the plugin's `FilterChainProxy`; the bean is created but never services requests. | `grails.plugin.springsecurity.filterChain.chainMap` / `filterNames` and `staticRules`. | +| `@Bean WebSecurityCustomizer` | No-op. The plugin does not use Spring's `WebSecurity` builder. | `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`. | +| `@Bean AuthenticationManager` | Conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean by name. | Register custom `AuthenticationProvider` beans and add their bean names to `grails.plugin.springsecurity.providerNames`. | +| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` | Coexists in the context but is not used by the plugin's authentication providers (which use the plugin's `userDetailsService` / `GormUserDetailsService`). | `grails.plugin.springsecurity.userLookup.userDomainClassName`, or replace the `userDetailsService` bean. | +| 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 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/plugin-core/docs/src/docs/introduction/installation.adoc b/plugin-core/docs/src/docs/introduction/installation.adoc index f491ab2c8..b13187127 100644 --- a/plugin-core/docs/src/docs/introduction/installation.adoc +++ b/plugin-core/docs/src/docs/introduction/installation.adoc @@ -78,6 +78,37 @@ The plugin automatically excludes Spring Boot's servlet security auto-configurat 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. The following table summarises how the plugin coexists with each component-based pattern when the auto-configuration excluder is enabled: + +[cols="1,1,1",options="header"] +|=== +| Spring component-based pattern | Behaviour with this plugin active | How to achieve the equivalent + +| `@Bean SecurityFilterChain` +| Not added to the plugin's `FilterChainProxy`; the bean is created but never services requests. +| `grails.plugin.springsecurity.filterChain.chainMap` / `filterNames` and `staticRules`. + +| `@Bean WebSecurityCustomizer` +| No-op. The plugin does not use Spring's `WebSecurity` builder. +| `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`. + +| `@Bean AuthenticationManager` +| Conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean by name. +| Register custom `AuthenticationProvider` beans and add their bean names to `grails.plugin.springsecurity.providerNames`. + +| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` +| Coexists in the context but is not used by the plugin's authentication providers (which use the plugin's `userDetailsService` / `GormUserDetailsService`). +| `grails.plugin.springsecurity.userLookup.userDomainClassName`, or replace the `userDetailsService` bean. + +| 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] 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 index 1a4f2e7be..a3353de17 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy @@ -54,6 +54,65 @@ import org.springframework.core.env.Environment * 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. + * The following table summarises how the plugin coexists with each + * component-based pattern when this filter is enabled:

+ * + *
    + *
  • {@code @Bean SecurityFilterChain} - user-defined + * {@code SecurityFilterChain} beans are NOT automatically added to the + * plugin's {@code FilterChainProxy} (the bean named + * {@code springSecurityFilterChain}). They live in the application + * context but never service requests. To customise the plugin's + * filter chain, configure + * {@code grails.plugin.springsecurity.filterChain.chainMap} and + * {@code grails.plugin.springsecurity.filterChain.filterNames} (or + * {@code staticRules}).
  • + *
  • {@code @Bean WebSecurityCustomizer} - 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, register them as Spring beans and add + * their bean names to {@code grails.plugin.springsecurity.providerNames}.
  • + *
  • {@code @Bean UserDetailsManager} / + * {@code InMemoryUserDetailsManager} / + * {@code JdbcUserDetailsManager} - the plugin registers a + * {@code userDetailsService} bean (a {@code GormUserDetailsService}). + * Additional {@code UserDetailsService} beans coexist in the context + * but are not used by the plugin's authentication providers. To + * customise user lookup, configure + * {@code grails.plugin.springsecurity.userLookup.userDomainClassName} + * (or replace the {@code userDetailsService} bean entirely).
  • + *
  • 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 From 3589ed6dd13b6947d3e2d98d43d60af5e86a3b4a Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 15:03:33 -0400 Subject: [PATCH 27/29] test: type integration spec field as ConfigurableApplicationContext Per Copilot PR #1215 review feedback (comments 3142335908 and 3142335915): - `ApplicationContext.getBeanFactory()` is not part of the `ApplicationContext` API; the previous code relied on Groovy dynamic dispatch / a specific context implementation. - Switch the autowired field type to `ConfigurableApplicationContext` and call `getBeanDefinition(...)` / `containsBeanDefinition(...)` on the returned `ConfigurableListableBeanFactory`. This makes the required Spring API explicit at the source level. Verified locally: `./gradlew :core-examples-integration-test-app:compileIntegrationTestGroovy` BUILD SUCCESSFUL. Assisted-by: claude-code:claude-4.6-opus --- ...onfigurationExcluderIntegrationSpec.groovy | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) 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 index ab2b10e2d..b77633e1d 100644 --- 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 @@ -21,7 +21,8 @@ package grails.plugin.springsecurity import spock.lang.Specification import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.ApplicationContext +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 @@ -31,7 +32,7 @@ import grails.testing.mixin.integration.Integration class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification { @Autowired - ApplicationContext applicationContext + ConfigurableApplicationContext applicationContext void "SecurityAutoConfigurationExcluder class is on the classpath"() { expect: @@ -41,24 +42,29 @@ class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification { } void "no Spring Boot SecurityFilterChain bean is registered alongside the plugin"() { - given: 'all SecurityFilterChain beans visible to the application context' + 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 -> - def def_ = applicationContext.getBeanFactory().getBeanDefinition(name) - !def_.beanClassName?.startsWith('org.springframework.boot.security.') + !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 !applicationContext.containsBeanDefinition(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: 'all UserDetailsService beans visible to the application context' + 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' @@ -66,8 +72,7 @@ class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification { and: 'none come from Spring Boot security auto-configurations' udsBeans.every { name -> - def def_ = applicationContext.getBeanFactory().getBeanDefinition(name) - !def_.beanClassName?.startsWith('org.springframework.boot.security.') + !beanFactory.getBeanDefinition(name).beanClassName?.startsWith('org.springframework.boot.security.') } and: "Boot's in-memory UserDetailsService is not present" From e21815883d0b8bae61d42ab29c904c0fb512439b Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 15:39:59 -0400 Subject: [PATCH 28/29] feat(componentbased): blend Grails plugin config with component-based Spring Security beans Adds `grails.plugin.springsecurity.componentbased.ComponentBasedConfigBlender` and `ChainedUserDetailsService` to make the Grails plugin's `grails.plugin.springsecurity.*` configuration coexist with the component-based Spring Security configuration model recommended by https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter Whether security is configured via Grails plugin keys, via Spring Security component beans, or both, the effective configuration is now the union of both sources rather than the plugin silently winning. Blending behaviour (each enabled by default, opt-out via `grails.plugin.springsecurity.componentBased.: false`): - `@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. Opt-out: `autoMergeSecurityFilterChain`. - `@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. Opt-out: `autoMergeAuthenticationProviders`. - `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` / any extra `UserDetailsService` -> auto-chained behind the plugin's primary `GormUserDetailsService` via `ChainedUserDetailsService`. The plugin's GORM lookup runs first; if it throws `UsernameNotFoundException`, each additional bean is queried in bean-name order. The chained service is wired into `daoAuthenticationProvider`. Opt-out: `autoChainUserDetailsServices`. - `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) and chained behind the plugin's primary user lookup. Opt-out: `bridgeSpringSecurityUserProperties`. `@Bean WebSecurityCustomizer` is still a no-op (the plugin does not use Spring's `WebSecurity` builder); use `staticRules` with `permitAll` or `ipRestrictions` instead. `@Bean AuthenticationManager` still conflicts with the plugin's `authenticationManager` bean by name; use `@Bean AuthenticationProvider` (auto-merged) or `grails.plugin.springsecurity.providerNames` instead. Wired into `SpringSecurityCoreGrailsPlugin.doWithApplicationContext` after the plugin's own filter chains and authentication providers are populated, so blending sees the final plugin state. `SecurityAutoConfigurationExcluder` Javadoc, `README.md` and `installation.adoc` updated to document the new blending behaviour (replacing the earlier "not blended" coexistence notes). Tests: 10 new unit tests in `ComponentBasedConfigBlenderSpec` covering prepend/append ordering, idempotent dedup, chained UDS query order, `UsernameNotFoundException` propagation, and the `spring.security.user.*` property bridge. Verified locally: `./gradlew :core-plugin:check` and `./gradlew :docs:asciidoctor` BUILD SUCCESSFUL. Assisted-by: claude-code:claude-4.6-opus --- README.md | 14 +- .../src/docs/introduction/installation.adoc | 32 ++- .../SecurityAutoConfigurationExcluder.groovy | 67 ++++-- .../SpringSecurityCoreGrailsPlugin.groovy | 48 ++++ .../ChainedUserDetailsService.groovy | 72 ++++++ .../ComponentBasedConfigBlender.groovy | 209 +++++++++++++++++ .../ComponentBasedConfigBlenderSpec.groovy | 211 ++++++++++++++++++ 7 files changed, 612 insertions(+), 41 deletions(-) create mode 100644 plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy create mode 100644 plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy create mode 100644 plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy diff --git a/README.md b/README.md index c1b895e41..322969927 100644 --- a/README.md +++ b/README.md @@ -58,14 +58,16 @@ The plugin automatically excludes Spring Boot's servlet security auto-configurat #### 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. The following table summarises how the plugin coexists with each component-based pattern when the auto-configuration excluder is enabled: +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 | Behaviour with this plugin active | How to achieve the equivalent | +| Spring component-based pattern | Blending behaviour with this plugin active | Disable via | |---|---|---| -| `@Bean SecurityFilterChain` | Not added to the plugin's `FilterChainProxy`; the bean is created but never services requests. | `grails.plugin.springsecurity.filterChain.chainMap` / `filterNames` and `staticRules`. | -| `@Bean WebSecurityCustomizer` | No-op. The plugin does not use Spring's `WebSecurity` builder. | `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`. | -| `@Bean AuthenticationManager` | Conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean by name. | Register custom `AuthenticationProvider` beans and add their bean names to `grails.plugin.springsecurity.providerNames`. | -| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` | Coexists in the context but is not used by the plugin's authentication providers (which use the plugin's `userDetailsService` / `GormUserDetailsService`). | `grails.plugin.springsecurity.userLookup.userDomainClassName`, or replace the `userDetailsService` bean. | +| `@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`) | **Auto-chained** behind the plugin's primary `GormUserDetailsService`; queried in bean-name order if the GORM lookup throws `UsernameNotFoundException`. The chained service is wired into `daoAuthenticationProvider`. | `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) and chained behind the plugin's primary user lookup. | `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. diff --git a/plugin-core/docs/src/docs/introduction/installation.adoc b/plugin-core/docs/src/docs/introduction/installation.adoc index b13187127..17f7fd402 100644 --- a/plugin-core/docs/src/docs/introduction/installation.adoc +++ b/plugin-core/docs/src/docs/introduction/installation.adoc @@ -80,27 +80,35 @@ IMPORTANT: While the plugin is active, `grails.plugin.springsecurity.*` is the a ==== 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. The following table summarises how the plugin coexists with each component-based pattern when the auto-configuration excluder is enabled: +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 | Behaviour with this plugin active | How to achieve the equivalent +| Spring component-based pattern | Blending behaviour with this plugin active | Disable via | `@Bean SecurityFilterChain` -| Not added to the plugin's `FilterChainProxy`; the bean is created but never services requests. -| `grails.plugin.springsecurity.filterChain.chainMap` / `filterNames` and `staticRules`. +| *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`) +| *Auto-chained* behind the plugin's primary `GormUserDetailsService`; queried in bean-name order if the GORM lookup throws `UsernameNotFoundException`. The chained service is wired into `daoAuthenticationProvider`. +| `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) and chained behind the plugin's primary user lookup. +| `grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false` | `@Bean WebSecurityCustomizer` -| No-op. The plugin does not use Spring's `WebSecurity` builder. -| `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`. +| 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. -| Register custom `AuthenticationProvider` beans and add their bean names to `grails.plugin.springsecurity.providerNames`. - -| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` -| Coexists in the context but is not used by the plugin's authentication providers (which use the plugin's `userDetailsService` / `GormUserDetailsService`). -| `grails.plugin.springsecurity.userLookup.userDomainClassName`, or replace the `userDetailsService` bean. +| 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. 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 index a3353de17..510790ece 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy @@ -63,23 +63,25 @@ import org.springframework.core.env.Environment * Spring Security without the WebSecurityConfigurerAdapter).

* *

This plugin pre-dates that model and provides equivalent functionality - * through the {@code grails.plugin.springsecurity.*} configuration namespace. - * The following table summarises how the plugin coexists with each - * component-based pattern when this filter is enabled:

+ * 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 NOT automatically added to the - * plugin's {@code FilterChainProxy} (the bean named - * {@code springSecurityFilterChain}). They live in the application - * context but never service requests. To customise the plugin's - * filter chain, configure - * {@code grails.plugin.springsecurity.filterChain.chainMap} and - * {@code grails.plugin.springsecurity.filterChain.filterNames} (or - * {@code staticRules}).
  • - *
  • {@code @Bean WebSecurityCustomizer} - no-op. The - * plugin does not use Spring's {@code WebSecurity} builder. To exclude - * URLs from security checks, use + * {@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.
  • @@ -87,17 +89,36 @@ import org.springframework.core.env.Environment * 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, register them as Spring beans and add - * their bean names to {@code grails.plugin.springsecurity.providerNames}. + * 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} - the plugin registers a - * {@code userDetailsService} bean (a {@code GormUserDetailsService}). - * Additional {@code UserDetailsService} beans coexist in the context - * but are not used by the plugin's authentication providers. To - * customise user lookup, configure - * {@code grails.plugin.springsecurity.userLookup.userDomainClassName} - * (or replace the {@code userDetailsService} bean entirely).
  • + * {@code JdbcUserDetailsManager} - additional + * {@code UserDetailsService} beans are auto-chained + * behind the plugin's primary {@code GormUserDetailsService} via + * {@link grails.plugin.springsecurity.componentbased.ChainedUserDetailsService}. + * The plugin's GORM-backed user lookup runs first; if it throws + * {@code UsernameNotFoundException}, each additional bean is queried in + * turn. The chained service is wired into + * {@code daoAuthenticationProvider}. 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) and + * chained behind the plugin's primary user lookup. Disable via + * {@code grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false}.
  • *
  • LDAP factory beans * ({@code EmbeddedLdapServerContextSourceFactoryBean}, * {@code LdapBindAuthenticationManagerFactory}, 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..9b1e76c8d 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,8 @@ 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.ChainedUserDetailsService +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 +82,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 +699,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 +774,49 @@ 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 chained = new ChainedUserDetailsService([primary] + additional) + applicationContext.daoAuthenticationProvider.userDetailsService = chained + log.info 'Wired chained UserDetailsService into daoAuthenticationProvider (primary GORM + {} additional)', additional.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:

    + * + *
    + * grails:
    + *   plugin:
    + *     springsecurity:
    + *       componentBased:
    + *         autoMergeSecurityFilterChain: false       # disable user @Bean SecurityFilterChain merge
    + *         autoMergeAuthenticationProviders: false   # disable user @Bean AuthenticationProvider merge
    + *         autoChainUserDetailsServices: false       # disable user @Bean UserDetailsService chaining
    + *         bridgeSpringSecurityUserProperties: false # disable spring.security.user.* property bridge
    + * 
    + * + * @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/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 + } +} From fbc7f8a840cd0fb6fecf3427b7de2f68ca05c1e5 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 16:04:32 -0400 Subject: [PATCH 29/29] fix(componentbased): add per-UDS DaoAuthenticationProvider instead of mutating the existing one The previous commit attempted to wire a chained UserDetailsService into the existing `daoAuthenticationProvider` via property assignment. That worked in pre-Spring-Security-7 but Spring Security 7 made `DaoAuthenticationProvider.userDetailsService` a final constructor-only field, so the assignment fails at startup with: groovy.lang.ReadOnlyPropertyException: Cannot set readonly property: userDetailsService for class: org.springframework.security.authentication.dao.DaoAuthenticationProvider This caused `ldap-examples-functional-test-app:integrationTest` to fail the application context startup (the LDAP example app exposes an `ldapUserDetailsService` bean which the blender then tried to chain). Fix: instead of mutating the existing `daoAuthenticationProvider`, create a NEW `DaoAuthenticationProvider` per additional `UserDetailsService` (each preserving the existing application `passwordEncoder` if present) and append them to the `authenticationManager` providers list. The plugin's primary GORM-backed provider remains first, so the GORM lookup still wins for known users; if that throws an authentication exception, each additional provider is tried in turn. Same observable behaviour, no readonly-field mutation. Verified locally: ./gradlew :ldap-examples-functional-test-app:integrationTest --rerun-tasks -> BUILD SUCCESSFUL Docs (Javadoc, README, installation.adoc) updated to describe the per-UDS-provider approach and to call out the final-field constraint that motivated it. Assisted-by: claude-code:claude-4.6-opus --- README.md | 4 ++-- .../src/docs/introduction/installation.adoc | 4 ++-- .../SecurityAutoConfigurationExcluder.groovy | 22 ++++++++++--------- .../SpringSecurityCoreGrailsPlugin.groovy | 15 +++++++++---- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 322969927..6caea0a69 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ Spring Security 5.7 deprecated and Spring Security 6 removed `WebSecurityConfigu |---|---|---| | `@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`) | **Auto-chained** behind the plugin's primary `GormUserDetailsService`; queried in bean-name order if the GORM lookup throws `UsernameNotFoundException`. The chained service is wired into `daoAuthenticationProvider`. | `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) and chained behind the plugin's primary user lookup. | `grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: 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. | diff --git a/plugin-core/docs/src/docs/introduction/installation.adoc b/plugin-core/docs/src/docs/introduction/installation.adoc index 17f7fd402..ab14d12f0 100644 --- a/plugin-core/docs/src/docs/introduction/installation.adoc +++ b/plugin-core/docs/src/docs/introduction/installation.adoc @@ -95,11 +95,11 @@ Spring Security 5.7 deprecated and Spring Security 6 removed `WebSecurityConfigu | `grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false` | `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` (or any extra `UserDetailsService`) -| *Auto-chained* behind the plugin's primary `GormUserDetailsService`; queried in bean-name order if the GORM lookup throws `UsernameNotFoundException`. The chained service is wired into `daoAuthenticationProvider`. +| 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) and chained behind the plugin's primary user lookup. +| 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` 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 index 510790ece..6cc0d4449 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy @@ -101,14 +101,15 @@ import org.springframework.core.env.Environment * {@code grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false}.
  • *
  • {@code @Bean UserDetailsManager} / * {@code InMemoryUserDetailsManager} / - * {@code JdbcUserDetailsManager} - additional - * {@code UserDetailsService} beans are auto-chained - * behind the plugin's primary {@code GormUserDetailsService} via - * {@link grails.plugin.springsecurity.componentbased.ChainedUserDetailsService}. - * The plugin's GORM-backed user lookup runs first; if it throws - * {@code UsernameNotFoundException}, each additional bean is queried in - * turn. The chained service is wired into - * {@code daoAuthenticationProvider}. Disable via + * {@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} / @@ -116,8 +117,9 @@ import org.springframework.core.env.Environment * {@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) and - * chained behind the plugin's primary user lookup. Disable via + * {@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}, 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 9b1e76c8d..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,7 +25,6 @@ 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.ChainedUserDetailsService import grails.plugin.springsecurity.componentbased.ComponentBasedConfigBlender import grails.plugin.springsecurity.userdetails.DefaultPostAuthenticationChecks import grails.plugin.springsecurity.userdetails.DefaultPreAuthenticationChecks @@ -810,9 +809,17 @@ to default to 'Annotation'; setting value to 'Annotation' } if (additional) { - def chained = new ChainedUserDetailsService([primary] + additional) - applicationContext.daoAuthenticationProvider.userDetailsService = chained - log.info 'Wired chained UserDetailsService into daoAuthenticationProvider (primary GORM + {} additional)', additional.size() + 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() } } }