diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java index 0467a0301a..6868703806 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/AuthorizationProxyConfiguration.java @@ -26,6 +26,9 @@ import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; +import org.springframework.security.aot.hint.AuthorizeReturnObjectCoreHintsRegistrar; +import org.springframework.security.aot.hint.SecurityHintsRegistrar; +import org.springframework.security.authorization.AuthorizationProxyFactory; import org.springframework.security.authorization.method.AuthorizationAdvisor; import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory; import org.springframework.security.authorization.method.AuthorizeReturnObjectMethodInterceptor; @@ -54,4 +57,10 @@ final class AuthorizationProxyConfiguration implements AopInfrastructureBean { return interceptor; } + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static SecurityHintsRegistrar authorizeReturnObjectHintsRegistrar(AuthorizationProxyFactory proxyFactory) { + return new AuthorizeReturnObjectCoreHintsRegistrar(proxyFactory); + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/EnableMethodSecurityAotTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/EnableMethodSecurityAotTests.java new file mode 100644 index 0000000000..9e7a047d36 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/EnableMethodSecurityAotTests.java @@ -0,0 +1,97 @@ +/* + * 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.config.annotation.method.configuration.aot; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.aot.generate.GenerationContext; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.aot.ApplicationContextAotGenerator; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * AOT Tests for {@code PrePostMethodSecurityConfiguration}. + * + * @author Evgeniy Cheban + * @author Josh Cummings + */ +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +public class EnableMethodSecurityAotTests { + + private final ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator(); + + private final GenerationContext context = new TestGenerationContext(); + + @Test + void whenProcessAheadOfTimeThenCreatesAuthorizationProxies() { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(AppConfig.class); + this.generator.processAheadOfTime(context, this.context); + RuntimeHints hints = this.context.getRuntimeHints(); + assertThat(hints.reflection().getTypeHint(TypeReference.of(cglibClassName(Message.class)))).isNotNull(); + assertThat(hints.reflection().getTypeHint(TypeReference.of(cglibClassName(User.class)))).isNotNull(); + assertThat(hints.proxies() + .jdkProxyHints() + .anyMatch((hint) -> hint.getProxiedInterfaces().contains(TypeReference.of(UserProjection.class)))).isTrue(); + } + + private static String cglibClassName(Class clazz) { + return clazz.getCanonicalName() + "$$SpringCGLIB$$0"; + } + + @Configuration + @EnableMethodSecurity + @EnableJpaRepositories + static class AppConfig { + + @Bean + DataSource dataSource() { + EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder(); + return builder.setType(EmbeddedDatabaseType.HSQL).build(); + } + + @Bean + LocalContainerEntityManagerFactoryBean entityManagerFactory() { + HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + vendorAdapter.setGenerateDdl(true); + LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); + factory.setJpaVendorAdapter(vendorAdapter); + factory.setPackagesToScan("org.springframework.security.config.annotation.method.configuration.aot"); + factory.setDataSource(dataSource()); + return factory; + } + + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/Message.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/Message.java new file mode 100644 index 0000000000..2ea294932b --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/Message.java @@ -0,0 +1,89 @@ +/* + * 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.config.annotation.method.configuration.aot; + +import java.time.Instant; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.method.AuthorizeReturnObject; + +@Entity +public class Message { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String text; + + private String summary; + + private Instant created = Instant.now(); + + @ManyToOne + private User to; + + @AuthorizeReturnObject + public User getTo() { + return this.to; + } + + public void setTo(User to) { + this.to = to; + } + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public Instant getCreated() { + return this.created; + } + + public void setCreated(Instant created) { + this.created = created; + } + + @PreAuthorize("hasAuthority('message:read')") + public String getText() { + return this.text; + } + + public void setText(String text) { + this.text = text; + } + + @PreAuthorize("hasAuthority('message:read')") + public String getSummary() { + return this.summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/MessageRepository.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/MessageRepository.java new file mode 100644 index 0000000000..9e281e3d6f --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/MessageRepository.java @@ -0,0 +1,39 @@ +/* + * 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.config.annotation.method.configuration.aot; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.security.authorization.method.AuthorizeReturnObject; +import org.springframework.stereotype.Repository; + +/** + * A repository for accessing {@link Message}s. + * + * @author Rob Winch + */ +@Repository +@AuthorizeReturnObject +public interface MessageRepository extends CrudRepository { + + @Query("select m from Message m where m.to.id = ?#{ authentication.name }") + Iterable findAll(); + + @Query("from org.springframework.security.config.annotation.method.configuration.aot.User u where u.id = ?#{ authentication.name }") + UserProjection findCurrentUser(); + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/User.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/User.java new file mode 100644 index 0000000000..52958356db --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/User.java @@ -0,0 +1,85 @@ +/* + * 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.config.annotation.method.configuration.aot; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +import org.springframework.security.access.prepost.PreAuthorize; + +/** + * A user. + * + * @author Rob Winch + */ +@Entity(name = "users") +public class User { + + @Id + private String id; + + private String firstName; + + private String lastName; + + private String email; + + private String password; + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + @PreAuthorize("hasAuthority('user:read')") + public String getFirstName() { + return this.firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + @PreAuthorize("hasAuthority('user:read')") + public String getLastName() { + return this.lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return this.email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return this.password; + } + + public void setPassword(String password) { + this.password = password; + } + +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/UserProjection.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/UserProjection.java new file mode 100644 index 0000000000..383f76728b --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/aot/UserProjection.java @@ -0,0 +1,25 @@ +/* + * 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.config.annotation.method.configuration.aot; + +public interface UserProjection { + + String getFirstName(); + + String getLastName(); + +} diff --git a/core/src/main/java/org/springframework/security/aot/hint/AuthorizeReturnObjectCoreHintsRegistrar.java b/core/src/main/java/org/springframework/security/aot/hint/AuthorizeReturnObjectCoreHintsRegistrar.java new file mode 100644 index 0000000000..bee6beb6f9 --- /dev/null +++ b/core/src/main/java/org/springframework/security/aot/hint/AuthorizeReturnObjectCoreHintsRegistrar.java @@ -0,0 +1,95 @@ +/* + * 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.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.security.authorization.AuthorizationProxyFactory; +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 beans for methods that use + * {@link AuthorizeReturnObject} and registers those return objects as + * {@link org.springframework.aot.hint.TypeHint}s. + * + *

+ * It also traverses those found types for other return values. + * + *

+ * An instance of this class is published as an infrastructural bean by the + * {@code spring-security-config} module. However, in the event you need to publish it + * yourself, remember to publish it as an infrastructural bean like so: + * + *

+ *	@Bean
+ *	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ *	static SecurityHintsRegistrar proxyThese(AuthorizationProxyFactory proxyFactory) {
+ *		return new AuthorizeReturnObjectHintsRegistrar(proxyFactory);
+ *	}
+ * 
+ * + * @author Josh Cummings + * @since 6.4 + * @see AuthorizeReturnObjectHintsRegistrar + * @see SecurityHintsAotProcessor + */ +public final class AuthorizeReturnObjectCoreHintsRegistrar implements SecurityHintsRegistrar { + + private final AuthorizationProxyFactory proxyFactory; + + private final SecurityAnnotationScanner scanner = SecurityAnnotationScanners + .requireUnique(AuthorizeReturnObject.class); + + private final Set> visitedClasses = new HashSet<>(); + + public AuthorizeReturnObjectCoreHintsRegistrar(AuthorizationProxyFactory proxyFactory) { + Assert.notNull(proxyFactory, "proxyFactory cannot be null"); + this.proxyFactory = proxyFactory; + } + + /** + * {@inheritDoc} + */ + @Override + public void registerHints(RuntimeHints hints, ConfigurableListableBeanFactory beanFactory) { + List> toProxy = new ArrayList<>(); + for (String name : beanFactory.getBeanDefinitionNames()) { + Class clazz = beanFactory.getType(name, false); + if (clazz == null) { + continue; + } + for (Method method : clazz.getDeclaredMethods()) { + AuthorizeReturnObject annotation = this.scanner.scan(method, clazz); + if (annotation == null) { + continue; + } + toProxy.add(method.getReturnType()); + } + } + new AuthorizeReturnObjectHintsRegistrar(this.proxyFactory, toProxy).registerHints(hints, beanFactory); + } + +} diff --git a/core/src/main/java/org/springframework/security/aot/hint/AuthorizeReturnObjectHintsRegistrar.java b/core/src/main/java/org/springframework/security/aot/hint/AuthorizeReturnObjectHintsRegistrar.java new file mode 100644 index 0000000000..65742c93e7 --- /dev/null +++ b/core/src/main/java/org/springframework/security/aot/hint/AuthorizeReturnObjectHintsRegistrar.java @@ -0,0 +1,143 @@ +/* + * 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.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.aop.SpringProxy; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.security.authorization.AuthorizationProxyFactory; +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} implementation that registers only the classes + * provided in the constructor. + * + *

+ * It also traverses those found types for other return values. + * + *

+ * This may be used by an application to register specific Security-adjacent classes that + * were otherwise missed by Spring Security's reachability scans. + * + *

+ * Remember to register this as an infrastructural bean like so: + * + *

+ *	@Bean
+ *	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ *	static SecurityHintsRegistrar proxyThese(AuthorizationProxyFactory proxyFactory) {
+ *		return new AuthorizationProxyFactoryHintsRegistrar(proxyFactory, MyClass.class);
+ *	}
+ * 
+ * + *

+ * Note that no object graph traversal is performed in this class. As such, any classes + * that need an authorization proxy that are missed by Security's default registrars + * should be listed exhaustively in the constructor. + * + * @author Josh Cummings + * @since 6.4 + * @see AuthorizeReturnObjectCoreHintsRegistrar + */ +public final class AuthorizeReturnObjectHintsRegistrar implements SecurityHintsRegistrar { + + private final AuthorizationProxyFactory proxyFactory; + + private final SecurityAnnotationScanner scanner = SecurityAnnotationScanners + .requireUnique(AuthorizeReturnObject.class); + + private final Set> visitedClasses = new HashSet<>(); + + private final List> classesToProxy; + + public AuthorizeReturnObjectHintsRegistrar(AuthorizationProxyFactory proxyFactory, Class... classes) { + Assert.notNull(proxyFactory, "proxyFactory cannot be null"); + Assert.noNullElements(classes, "classes cannot contain null elements"); + this.proxyFactory = proxyFactory; + this.classesToProxy = new ArrayList(List.of(classes)); + } + + /** + * Construct this registrar + * @param proxyFactory the proxy factory to use to produce the proxy class + * implementations to be registered + * @param classes the classes to proxy + */ + public AuthorizeReturnObjectHintsRegistrar(AuthorizationProxyFactory proxyFactory, List> classes) { + this.proxyFactory = proxyFactory; + this.classesToProxy = new ArrayList<>(classes); + } + + /** + * {@inheritDoc} + */ + @Override + public void registerHints(RuntimeHints hints, ConfigurableListableBeanFactory beanFactory) { + List> toProxy = new ArrayList<>(); + for (Class clazz : this.classesToProxy) { + toProxy.add(clazz); + traverseType(toProxy, clazz); + } + for (Class clazz : toProxy) { + registerProxy(hints, clazz); + } + } + + private void registerProxy(RuntimeHints hints, Class clazz) { + Class proxied = (Class) this.proxyFactory.proxy(clazz); + if (proxied == null) { + return; + } + if (Proxy.isProxyClass(proxied)) { + hints.proxies().registerJdkProxy(proxied.getInterfaces()); + return; + } + if (SpringProxy.class.isAssignableFrom(proxied)) { + hints.reflection() + .registerType(proxied, MemberCategory.INVOKE_PUBLIC_METHODS, MemberCategory.PUBLIC_FIELDS, + MemberCategory.DECLARED_FIELDS); + } + } + + private void traverseType(List> toProxy, Class clazz) { + if (clazz == Object.class || this.visitedClasses.contains(clazz)) { + return; + } + this.visitedClasses.add(clazz); + for (Method m : clazz.getDeclaredMethods()) { + AuthorizeReturnObject object = this.scanner.scan(m, clazz); + if (object == null) { + continue; + } + Class returnType = m.getReturnType(); + toProxy.add(returnType); + traverseType(toProxy, returnType); + } + } + +} diff --git a/core/src/test/java/org/springframework/security/aot/hint/AuthorizeReturnObjectCoreHintsRegistrarTests.java b/core/src/test/java/org/springframework/security/aot/hint/AuthorizeReturnObjectCoreHintsRegistrarTests.java new file mode 100644 index 0000000000..0817114470 --- /dev/null +++ b/core/src/test/java/org/springframework/security/aot/hint/AuthorizeReturnObjectCoreHintsRegistrarTests.java @@ -0,0 +1,104 @@ +/* + * 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.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.security.authorization.AuthorizationProxyFactory; +import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.method.AuthorizeReturnObject; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link AuthorizeReturnObjectCoreHintsRegistrar} + */ +public class AuthorizeReturnObjectCoreHintsRegistrarTests { + + private final AuthorizationProxyFactory proxyFactory = spy(AuthorizationAdvisorProxyFactory.withDefaults()); + + private final AuthorizeReturnObjectCoreHintsRegistrar registrar = new AuthorizeReturnObjectCoreHintsRegistrar( + this.proxyFactory); + + @Test + public void registerHintsWhenUsingAuthorizeReturnObjectThenRegisters() { + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean(MyService.class, MyService::new); + context.registerBean(MyInterface.class, MyImplementation::new); + context.refresh(); + RuntimeHints hints = new RuntimeHints(); + this.registrar.registerHints(hints, context.getBeanFactory()); + assertThat(hints.reflection().typeHints().map((hint) -> hint.getType().getName())) + .containsOnly(cglibClassName(MyObject.class), cglibClassName(MySubObject.class)); + assertThat(hints.proxies() + .jdkProxyHints() + .flatMap((hint) -> hint.getProxiedInterfaces().stream()) + .map(TypeReference::getName)).contains(MyInterface.class.getName()); + } + + private static String cglibClassName(Class clazz) { + return clazz.getName() + "$$SpringCGLIB$$0"; + } + + public static class MyService { + + @AuthorizeReturnObject + MyObject get() { + return new MyObject(); + } + + } + + public interface MyInterface { + + MyObject get(); + + } + + @AuthorizeReturnObject + public static class MyImplementation implements MyInterface { + + @Override + public MyObject get() { + return new MyObject(); + } + + } + + public static class MyObject { + + @AuthorizeReturnObject + public MySubObject get() { + return new MySubObject(); + } + + @AuthorizeReturnObject + public MyInterface getInterface() { + return new MyImplementation(); + } + + } + + public static class MySubObject { + + } + +} diff --git a/core/src/test/java/org/springframework/security/aot/hint/AuthorizeReturnObjectHintsRegistrarTests.java b/core/src/test/java/org/springframework/security/aot/hint/AuthorizeReturnObjectHintsRegistrarTests.java new file mode 100644 index 0000000000..043e128d78 --- /dev/null +++ b/core/src/test/java/org/springframework/security/aot/hint/AuthorizeReturnObjectHintsRegistrarTests.java @@ -0,0 +1,64 @@ +/* + * 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.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; +import org.springframework.security.authorization.AuthorizationProxyFactory; +import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link AuthorizeReturnObjectHintsRegistrar} + */ +public class AuthorizeReturnObjectHintsRegistrarTests { + + private final AuthorizationProxyFactory proxyFactory = spy(AuthorizationAdvisorProxyFactory.withDefaults()); + + @Test + public void registerHintsWhenSpecifiedThenRegisters() { + AuthorizeReturnObjectHintsRegistrar registrar = new AuthorizeReturnObjectHintsRegistrar(this.proxyFactory, + MyObject.class, MyInterface.class); + RuntimeHints hints = new RuntimeHints(); + registrar.registerHints(hints, null); + assertThat(hints.reflection().typeHints().map((hint) -> hint.getType().getName())) + .containsOnly(cglibClassName(MyObject.class)); + assertThat(hints.proxies() + .jdkProxyHints() + .flatMap((hint) -> hint.getProxiedInterfaces().stream()) + .map(TypeReference::getName)).contains(MyInterface.class.getName()); + } + + private static String cglibClassName(Class clazz) { + return clazz.getName() + "$$SpringCGLIB$$0"; + } + + public interface MyInterface { + + MyObject get(); + + } + + public static class MyObject { + + } + +} diff --git a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc index 0ab024a3d3..1dc4ff0065 100644 --- a/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc +++ b/docs/modules/ROOT/pages/servlet/authorization/method-security.adoc @@ -2105,11 +2105,6 @@ fun getEmailWhenProxiedThenAuthorizes() { ---- ====== -[NOTE] -==== -This feature does not yet support Spring AOT -==== - === Proxying Collections `AuthorizationProxyFactory` supports Java collections, streams, arrays, optionals, and iterators by proxying the element type and maps by proxying the value type. @@ -2297,6 +2292,164 @@ And if they do have that authority, they'll see: You can also add the Spring Boot property `spring.jackson.default-property-inclusion=non_null` to exclude the null value from serialization, if you also don't want to reveal the JSON key to an unauthorized user. ==== +=== Working with AOT + +Spring Security will scan all beans in the application context for methods that use `@AuthorizeReturnObject`. +When it finds one, it will create and register the appropriate proxy class ahead of time. +It will also recursively search for other nested objects that also use `@AuthorizeReturnObject` and register them accordingly. + +For example, consider the following Spring Boot application: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@SpringBootApplication +public class MyApplication { + @RestController + public static class MyController { <1> + @GetMapping + @AuthorizeReturnObject + Message getMessage() { <2> + return new Message(someUser, "hello!"); + } + } + + public static class Message { <3> + User to; + String text; + + // ... + + @AuthorizeReturnObject + public User getTo() { <4> + return this.to; + } + + // ... + } + + public static class User { <5> + // ... + } + + public static void main(String[] args) { + SpringApplication.run(MyApplication.class); + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@SpringBootApplication +open class MyApplication { + @RestController + open class MyController { <1> + @GetMapping + @AuthorizeReturnObject + fun getMessage():Message { <2> + return Message(someUser, "hello!") + } + } + + open class Message { <3> + val to: User + val test: String + + // ... + + @AuthorizeReturnObject + fun getTo(): User { <4> + return this.to + } + + // ... + } + + open class User { <5> + // ... + } + + fun main(args: Array) { + SpringApplication.run(MyApplication.class) + } +} +---- +====== +<1> - First, Spring Security finds the `MyController` bean +<2> - Finding a method that uses `@AuthorizeReturnObject`, it proxies `Message`, the return value, and registers that proxy class to `RuntimeHints` +<3> - Then, it traverses `Message` to see if it uses `@AuthorizeReturnObject` +<4> - Finding a method that uses `@AuthorizeReturnObject`, it proxies `User`, the return value, and registers that proxy class to `RuntimeHints` +<5> - Finally, it traverses `User` to see if it uses `@AuthorizeReturnObject`; finding nothing, the algorithm completes + +There will be many times when Spring Security cannot determine the proxy class ahead of time since it may be hidden in an erased generic type. + +Consider the following change to `MyController`: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@RestController +public static class MyController { + @GetMapping + @AuthorizeReturnObject + List getMessages() { + return List.of(new Message(someUser, "hello!")); + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@RestController +static class MyController { + @AuthorizeReturnObject + @GetMapping + fun getMessages(): Array = arrayOf(Message(someUser, "hello!")) +} +---- +====== + +In this case, the generic type is erased and so it isn't apparent to Spring Security ahead-of-time that `Message` will need to be proxied at runtime. + +To address this, you can publish `AuthorizeProxyFactoryHintsRegistrar` like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +static SecurityHintsRegsitrar registerTheseToo(AuthorizationProxyFactory proxyFactory) { + return new AuthorizeReturnObjectHintsRegistrar(proxyFactory, Message.class); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +fun registerTheseToo(proxyFactory: AuthorizationProxyFactory?): SecurityHintsRegistrar { + return AuthorizeReturnObjectHintsRegistrar(proxyFactory, Message::class.java) +} +---- +====== + +Spring Security will register that class and then traverse its type as before. + [[fallback-values-authorization-denied]] == Providing Fallback Values When Authorization is Denied