Provide Runtime Hints for Beans used in Pre/PostAuthorize Expressions

Closes gh-14652
This commit is contained in:
Marcus Hert Da Coregio 2024-09-11 15:21:40 -03:00
parent 61efede09e
commit 0618d4e03f
5 changed files with 733 additions and 0 deletions

View File

@ -35,6 +35,8 @@ import org.springframework.core.type.AnnotationMetadata;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.aot.hint.PrePostAuthorizeHintsRegistrar;
import org.springframework.security.aot.hint.SecurityHintsRegistrar;
import org.springframework.security.authorization.AuthorizationEventPublisher;
import org.springframework.security.authorization.ObservationAuthorizationManager;
import org.springframework.security.authorization.method.AuthorizationManagerAfterMethodInterceptor;
@ -191,6 +193,12 @@ final class PrePostMethodSecurityConfiguration implements ImportAware, Applicati
() -> _prePostMethodSecurityConfiguration.getObject().postFilterMethodInterceptor);
}
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static SecurityHintsRegistrar prePostAuthorizeExpressionHintsRegistrar() {
return new PrePostAuthorizeHintsRegistrar();
}
@Override
public void setImportMetadata(AnnotationMetadata importMetadata) {
EnableMethodSecurity annotation = importMetadata.getAnnotations().get(EnableMethodSecurity.class).synthesize();

View File

@ -0,0 +1,158 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.aot.hint;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.TypeReference;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.expression.spel.SpelNode;
import org.springframework.expression.spel.ast.BeanReference;
import org.springframework.expression.spel.standard.SpelExpression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authorization.method.AuthorizeReturnObject;
import org.springframework.security.core.annotation.SecurityAnnotationScanner;
import org.springframework.security.core.annotation.SecurityAnnotationScanners;
import org.springframework.util.Assert;
/**
* A {@link SecurityHintsRegistrar} that scans all provided classes for methods that use
* {@link PreAuthorize} or {@link PostAuthorize} and registers hints for the beans used
* within the security expressions.
*
* <p>
* It will also scan return types of methods annotated with {@link AuthorizeReturnObject}.
*
* <p>
* This may be used by an application to register specific Security-adjacent classes that
* were otherwise missed by Spring Security's reachability scans.
*
* <p>
* Remember to register this as an infrastructural bean like so:
*
* <pre>
* &#064;Bean
* &#064;Role(BeanDefinition.ROLE_INFRASTRUCTURE)
* static SecurityHintsRegistrar registerThese() {
* return new PrePostAuthorizeExpressionBeanHintsRegistrar(MyClass.class);
* }
* </pre>
*
* @author Marcus da Coregio
* @since 6.4
* @see SecurityHintsAotProcessor
*/
public final class PrePostAuthorizeExpressionBeanHintsRegistrar implements SecurityHintsRegistrar {
private final SecurityAnnotationScanner<PreAuthorize> preAuthorizeScanner = SecurityAnnotationScanners
.requireUnique(PreAuthorize.class);
private final SecurityAnnotationScanner<PostAuthorize> postAuthorizeScanner = SecurityAnnotationScanners
.requireUnique(PostAuthorize.class);
private final SecurityAnnotationScanner<AuthorizeReturnObject> authorizeReturnObjectScanner = SecurityAnnotationScanners
.requireUnique(AuthorizeReturnObject.class);
private final SpelExpressionParser expressionParser = new SpelExpressionParser();
private final Set<Class<?>> visitedClasses = new HashSet<>();
private final List<Class<?>> toVisit;
public PrePostAuthorizeExpressionBeanHintsRegistrar(Class<?>... toVisit) {
this(Arrays.asList(toVisit));
}
public PrePostAuthorizeExpressionBeanHintsRegistrar(List<Class<?>> toVisit) {
Assert.notEmpty(toVisit, "toVisit cannot be empty");
Assert.noNullElements(toVisit, "toVisit cannot contain null elements");
this.toVisit = toVisit;
}
@Override
public void registerHints(RuntimeHints hints, ConfigurableListableBeanFactory beanFactory) {
Set<String> expressions = new HashSet<>();
for (Class<?> bean : this.toVisit) {
expressions.addAll(extractSecurityExpressions(bean));
}
Set<String> beanNamesToRegister = new HashSet<>();
for (String expression : expressions) {
beanNamesToRegister.addAll(extractBeanNames(expression));
}
for (String toRegister : beanNamesToRegister) {
Class<?> type = beanFactory.getType(toRegister, false);
if (type == null) {
continue;
}
hints.reflection().registerType(TypeReference.of(type), MemberCategory.INVOKE_DECLARED_METHODS);
}
}
private Set<String> extractSecurityExpressions(Class<?> clazz) {
if (this.visitedClasses.contains(clazz)) {
return Collections.emptySet();
}
this.visitedClasses.add(clazz);
Set<String> expressions = new HashSet<>();
for (Method method : clazz.getDeclaredMethods()) {
PreAuthorize preAuthorize = this.preAuthorizeScanner.scan(method, clazz);
PostAuthorize postAuthorize = this.postAuthorizeScanner.scan(method, clazz);
if (preAuthorize != null) {
expressions.add(preAuthorize.value());
}
if (postAuthorize != null) {
expressions.add(postAuthorize.value());
}
AuthorizeReturnObject authorizeReturnObject = this.authorizeReturnObjectScanner.scan(method, clazz);
if (authorizeReturnObject != null) {
expressions.addAll(extractSecurityExpressions(method.getReturnType()));
}
}
return expressions;
}
private Set<String> extractBeanNames(String rawExpression) {
SpelExpression expression = this.expressionParser.parseRaw(rawExpression);
SpelNode node = expression.getAST();
Set<String> beanNames = new HashSet<>();
resolveBeanNames(beanNames, node);
return beanNames;
}
private void resolveBeanNames(Set<String> beanNames, SpelNode node) {
if (node instanceof BeanReference br) {
beanNames.add(br.getName());
}
int childCount = node.getChildCount();
if (childCount == 0) {
return;
}
for (int i = 0; i < childCount; i++) {
resolveBeanNames(beanNames, node.getChild(i));
}
}
}

View File

@ -0,0 +1,49 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.aot.hint;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.RegisteredBean;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
/**
* A {@link SecurityHintsRegistrar} that scans all beans for methods that use
* {@link PreAuthorize} or {@link PostAuthorize} and registers appropriate hints for the
* annotations.
*
* @author Marcus da Coregio
* @since 6.4
* @see SecurityHintsAotProcessor
* @see PrePostAuthorizeExpressionBeanHintsRegistrar
*/
public final class PrePostAuthorizeHintsRegistrar implements SecurityHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ConfigurableListableBeanFactory beanFactory) {
List<Class<?>> beans = Arrays.stream(beanFactory.getBeanDefinitionNames())
.map((beanName) -> RegisteredBean.of(beanFactory, beanName).getBeanClass())
.collect(Collectors.toList());
new PrePostAuthorizeExpressionBeanHintsRegistrar(beans).registerHints(hints, beanFactory);
}
}

View File

@ -0,0 +1,348 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.aot.hint;
import org.junit.jupiter.api.Test;
import org.springframework.aot.generate.GenerationContext;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
import org.springframework.aot.test.generate.TestGenerationContext;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authorization.method.AuthorizeReturnObject;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
class PrePostAuthorizeHintsRegistrarTests {
private final PrePostAuthorizeHintsRegistrar registrar = new PrePostAuthorizeHintsRegistrar();
private final GenerationContext generationContext = new TestGenerationContext();
@Test
void registerHintsWhenPreAuthorizeOnTypeThenHintsRegistered() {
process(Authz.class, PreAuthorizeOnClass.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenPostAuthorizeOnTypeThenHintsRegistered() {
process(Authz.class, PostAuthorizeOnClass.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenPreAuthorizeOnMethodsThenHintsRegistered() {
process(Authz.class, Foo.class, PreAuthorizeOnMethods.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Foo.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenPostAuthorizeOnMethodsThenHintsRegistered() {
process(Authz.class, Foo.class, PostAuthorizeOnMethods.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Foo.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenPreAuthorizeExpressionWithMultipleBeansThenRegisterHintsForAllBeans() {
process(Authz.class, Foo.class, PreAuthorizeMultipleBeans.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Foo.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenPostAuthorizeExpressionWithMultipleBeansThenRegisterHintsForAllBeans() {
process(Authz.class, Foo.class, PostAuthorizeMultipleBeans.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Foo.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenPreAuthorizeOnTypeAndMethodThenRegisterHintsForBoth() {
process(Authz.class, Foo.class, PreAuthorizeOnTypeAndMethod.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Foo.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenPostAuthorizeOnTypeAndMethodThenRegisterHintsForBoth() {
process(Authz.class, Foo.class, PostAuthorizeOnTypeAndMethod.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Foo.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenSecurityAnnotationsInsideAuthorizeReturnObjectOnMethodThenRegisterHints() {
process(AccountAuthz.class, Authz.class, PreAuthorizeInsideAuthorizeReturnObjectOnMethod.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(AccountAuthz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenSecurityAnnotationsInsideAuthorizeReturnObjectOnClassThenRegisterHints() {
process(AccountAuthz.class, Authz.class, PreAuthorizeInsideAuthorizeReturnObjectOnClass.class);
assertThat(RuntimeHintsPredicates.reflection()
.onType(AccountAuthz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
assertThat(RuntimeHintsPredicates.reflection()
.onType(Authz.class)
.withMemberCategory(MemberCategory.INVOKE_DECLARED_METHODS))
.accepts(this.generationContext.getRuntimeHints());
}
@Test
void registerHintsWhenCyclicDependencyThenNoStackOverflowException() {
assertThatNoException().isThrownBy(() -> process(AService.class));
}
private void process(Class<?>... beanClasses) {
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
for (Class<?> beanClass : beanClasses) {
beanFactory.registerBeanDefinition(beanClass.getSimpleName().toLowerCase(),
new RootBeanDefinition(beanClass));
}
this.registrar.registerHints(this.generationContext.getRuntimeHints(), beanFactory);
}
@PreAuthorize("@authz.check()")
static class PreAuthorizeOnClass {
}
@PostAuthorize("@authz.check()")
static class PostAuthorizeOnClass {
}
static class PreAuthorizeOnMethods {
@PreAuthorize("@authz.check()")
void method1() {
}
@PreAuthorize("@foo.bar()")
void method2() {
}
}
static class PostAuthorizeOnMethods {
@PostAuthorize("@authz.check()")
void method1() {
}
@PostAuthorize("@foo.bar()")
void method2() {
}
}
static class PreAuthorizeMultipleBeans {
@PreAuthorize("@authz.check() ? true : @foo.bar()")
void method1() {
}
}
static class PostAuthorizeMultipleBeans {
@PostAuthorize("@authz.check() ? true : @foo.bar()")
void method1() {
}
}
@PreAuthorize("@authz.check()")
static class PreAuthorizeOnTypeAndMethod {
@PreAuthorize("@foo.bar()")
void method1() {
}
}
@PostAuthorize("@authz.check()")
static class PostAuthorizeOnTypeAndMethod {
@PostAuthorize("@foo.bar()")
void method1() {
}
}
static class PreAuthorizeInsideAuthorizeReturnObjectOnMethod {
@AuthorizeReturnObject
Account getAccount() {
return new Account("1234");
}
}
@AuthorizeReturnObject
static class PreAuthorizeInsideAuthorizeReturnObjectOnClass {
Account getAccount() {
return new Account("1234");
}
}
static class Authz {
boolean check() {
return true;
}
}
static class Foo {
boolean bar() {
return true;
}
}
static class AccountAuthz {
boolean canViewAccountNumber() {
return true;
}
}
static class Account {
private final String accountNumber;
Account(String accountNumber) {
this.accountNumber = accountNumber;
}
@PreAuthorize("@accountauthz.canViewAccountNumber()")
String getAccountNumber() {
return this.accountNumber;
}
@AuthorizeReturnObject
User getUser() {
return new User("John Doe");
}
}
static class User {
private final String fullName;
User(String fullName) {
this.fullName = fullName;
}
@PostAuthorize("@authz.check()")
String getFullName() {
return this.fullName;
}
}
static class AService {
@AuthorizeReturnObject
A getA() {
return new A();
}
}
static class A {
@AuthorizeReturnObject
B getB() {
return null;
}
}
static class B {
@AuthorizeReturnObject
A getA() {
return null;
}
}
}

View File

@ -1528,6 +1528,176 @@ We expose `MethodSecurityExpressionHandler` using a `static` method to ensure th
You can also <<subclass-defaultmethodsecurityexpressionhandler,subclass `DefaultMessageSecurityExpressionHandler`>> to add your own custom authorization expressions beyond the defaults.
=== Working with AOT
Spring Security will scan all beans in the application context for methods that use `@PreAuthorize` or `@PostAuthorize`.
When it finds one, it will resolve any beans used inside the security expression and register the appropriate runtime hints for that bean.
If it finds a method that uses `@AuthorizeReturnObject`, it will recursively search inside the method's return type for `@PreAuthorize` and `@PostAuthorize` annotations and register them accordingly.
For example, consider the following Spring Boot application:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Service
public class AccountService { <1>
@PreAuthorize("@authz.decide()") <2>
@AuthorizeReturnObject <3>
public Account getAccountById(String accountId) {
// ...
}
}
public class Account {
private final String accountNumber;
// ...
@PreAuthorize("@accountAuthz.canViewAccountNumber()") <4>
public String getAccountNumber() {
return this.accountNumber;
}
@AuthorizeReturnObject <5>
public User getUser() {
return new User("John Doe");
}
}
public class User {
private final String fullName;
// ...
@PostAuthorize("@myOtherAuthz.decide()") <6>
public String getFullName() {
return this.fullName;
}
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Service
class AccountService { <1>
@PreAuthorize("@authz.decide()") <2>
@AuthorizeReturnObject <3>
fun getAccountById(accountId: String): Account {
// ...
}
}
class Account(private val accountNumber: String) {
@PreAuthorize("@accountAuthz.canViewAccountNumber()") <4>
fun getAccountNumber(): String {
return this.accountNumber
}
@AuthorizeReturnObject <5>
fun getUser(): User {
return User("John Doe")
}
}
class User(private val fullName: String) {
@PostAuthorize("@myOtherAuthz.decide()") <6>
fun getFullName(): String {
return this.fullName
}
}
----
======
<1> Spring Security finds the `AccountService` bean
<2> Finding a method that uses `@PreAuthorize`, it will resolve any bean names used inside the expression, `authz` in that case, and register runtime hints for the bean class
<3> Finding a method that uses `@AuthorizeReturnObject`, it will look into the method's return type for any `@PreAuthorize` or `@PostAuthorize`
<4> Then, it finds a `@PreAuthorize` with another bean name: `accountAuthz`; the runtime hints are registered for the bean class as well
<5> Finding another `@AuthorizeReturnObject` it will look again into the method's return type
<6> Now, a `@PostAuthorize` is found with yet another bean name used: `myOtherAuthz`; the runtime hints are registered for the bean class as well
There will be many times when Spring Security cannot determine the actual return type of the method ahead of time since it may be hidden in an erased generic type.
Consider the following service:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Service
public class AccountService {
@AuthorizeReturnObject
public List<Account> getAllAccounts() {
// ...
}
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Service
class AccountService {
@AuthorizeReturnObject
fun getAllAccounts(): List<Account> {
// ...
}
}
----
======
In this case, the generic type is erased and so it isnt apparent to Spring Security ahead-of-time that `Account` needs to be visited in order to check for `@PreAuthorize` and `@PostAuthorize`.
To address this, you can publish a javadoc:org.springframework.security.aot.hint.PrePostAuthorizeExpressionBeanHintsRegistrar[`PrePostAuthorizeExpressionBeanHintsRegistrar`] like so:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static SecurityHintsRegistrar registerTheseToo() {
return new PrePostAuthorizeExpressionBeanHintsRegistrar(Account.class);
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
fun registerTheseToo(): SecurityHintsRegistrar {
return PrePostAuthorizeExpressionBeanHintsRegistrar(Account::class.java)
}
----
======
[[use-aspectj]]
== Authorizing with AspectJ