Skip to content

Commit 2b7d296

Browse files
sbrannenjzheaux
authored andcommitted
Revise AuthorizationAnnotationUtils
This commit revises AuthorizationAnnotationUtils as follows. - Removes code duplication by treating both Class and Method as AnnotatedElement. - Avoids duplicated annotation searches by processing merged annotations in a single Stream instead of first using the MergedAnnotations API to find possible duplicates and then again searching for a single annotation via AnnotationUtils (which effectively performs the same search using the MergedAnnotations API internally). - Uses `.distinct()` within the Stream to avoid the need for the workaround introduced in gh-13625. Note that the semantics here result in duplicate "equivalent" annotations being ignored. In other words, if @⁠PreAuthorize("hasRole('someRole')") is present multiple times as a meta-annotation, no exception will be thrown and the first such annotation found will be used. - Improves the error message when competing annotations are found by including the competing annotations in the error message. - Updates AuthorizationAnnotationUtilsTests to cover all known, supported use cases. - Configures correct role in @⁠RequireUserRole. Please note this commit uses `.map(MergedAnnotation::withNonMergedAttributes)` to retain backward compatibility with previous versions of Spring Security. However, that line can be deleted if the Spring Security team decides that it wishes to support merged annotation attributes via custom composed annotations. If that decision is made, the composedMergedAnnotationsAreNotSupported() test should be renamed and updated as explained in the comment in that method. See gh-13625 See spring-projects/spring-framework#31803
1 parent 3f65f60 commit 2b7d296

File tree

3 files changed

+160
-78
lines changed

3 files changed

+160
-78
lines changed

core/src/main/java/org/springframework/security/authorization/method/AuthorizationAnnotationUtils.java

+45-68
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,31 +17,36 @@
1717
package org.springframework.security.authorization.method;
1818

1919
import java.lang.annotation.Annotation;
20-
import java.lang.reflect.Executable;
20+
import java.lang.reflect.AnnotatedElement;
2121
import java.lang.reflect.Method;
22+
import java.util.List;
2223

2324
import org.springframework.core.annotation.AnnotationConfigurationException;
24-
import org.springframework.core.annotation.AnnotationUtils;
2525
import org.springframework.core.annotation.MergedAnnotation;
2626
import org.springframework.core.annotation.MergedAnnotations;
27+
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
2728
import org.springframework.core.annotation.RepeatableContainers;
2829

2930
/**
30-
* A wrapper around {@link AnnotationUtils} that checks for, and errors on, conflicting
31-
* annotations. This is specifically important for Spring Security annotations which are
32-
* not designed to be repeatable.
31+
* A collection of utility methods that check for, and error on, conflicting annotations.
32+
* This is specifically important for Spring Security annotations which are not designed
33+
* to be repeatable.
3334
*
35+
* <p>
3436
* There are numerous ways that two annotations of the same type may be attached to the
3537
* same method. For example, a class may implement a method defined in two separate
36-
* interfaces. If both of those interfaces have a `@PreAuthorize` annotation, then it's
37-
* unclear which `@PreAuthorize` expression Spring Security should use.
38+
* interfaces. If both of those interfaces have a {@code @PreAuthorize} annotation, then
39+
* it's unclear which {@code @PreAuthorize} expression Spring Security should use.
3840
*
41+
* <p>
3942
* Another way is when one of Spring Security's annotations is used as a meta-annotation.
4043
* In that case, two custom annotations can be declared, each with their own
41-
* `@PreAuthorize` declaration. If both custom annotations are used on the same method,
42-
* then it's unclear which `@PreAuthorize` expression Spring Security should use.
44+
* {@code @PreAuthorize} declaration. If both custom annotations are used on the same
45+
* method, then it's unclear which {@code @PreAuthorize} expression Spring Security should
46+
* use.
4347
*
4448
* @author Josh Cummings
49+
* @author Sam Brannen
4550
*/
4651
final class AuthorizationAnnotationUtils {
4752

@@ -50,84 +55,56 @@ final class AuthorizationAnnotationUtils {
5055
* the annotation of type {@code annotationType}, including any annotations using
5156
* {@code annotationType} as a meta-annotation.
5257
*
53-
* If more than one is found, then throw an error.
58+
* <p>
59+
* If more than one unique annotation is found, then throw an error.
5460
* @param method the method declaration to search from
5561
* @param annotationType the annotation type to search for
56-
* @return the unique instance of the annotation attributed to the method,
57-
* {@code null} otherwise
58-
* @throws AnnotationConfigurationException if more than one instance of the
62+
* @return a unique instance of the annotation attributed to the method, {@code null}
63+
* otherwise
64+
* @throws AnnotationConfigurationException if more than one unique instance of the
5965
* annotation is found
6066
*/
6167
static <A extends Annotation> A findUniqueAnnotation(Method method, Class<A> annotationType) {
62-
MergedAnnotations mergedAnnotations = MergedAnnotations.from(method,
63-
MergedAnnotations.SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none());
64-
if (hasDuplicate(mergedAnnotations, annotationType)) {
65-
throw new AnnotationConfigurationException("Found more than one annotation of type " + annotationType
66-
+ " attributed to " + method
67-
+ " Please remove the duplicate annotations and publish a bean to handle your authorization logic.");
68-
}
69-
return AnnotationUtils.findAnnotation(method, annotationType);
68+
return findDistinctAnnotation(method, annotationType);
7069
}
7170

7271
/**
7372
* Perform an exhaustive search on the type hierarchy of the given {@link Class} for
7473
* the annotation of type {@code annotationType}, including any annotations using
7574
* {@code annotationType} as a meta-annotation.
7675
*
77-
* If more than one is found, then throw an error.
76+
* <p>
77+
* If more than one unique annotation is found, then throw an error.
7878
* @param type the type to search from
7979
* @param annotationType the annotation type to search for
80-
* @return the unique instance of the annotation attributed to the method,
81-
* {@code null} otherwise
82-
* @throws AnnotationConfigurationException if more than one instance of the
80+
* @return a unique instance of the annotation attributed to the class, {@code null}
81+
* otherwise
82+
* @throws AnnotationConfigurationException if more than one unique instance of the
8383
* annotation is found
8484
*/
8585
static <A extends Annotation> A findUniqueAnnotation(Class<?> type, Class<A> annotationType) {
86-
MergedAnnotations mergedAnnotations = MergedAnnotations.from(type,
87-
MergedAnnotations.SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none());
88-
if (hasDuplicate(mergedAnnotations, annotationType)) {
89-
throw new AnnotationConfigurationException("Found more than one annotation of type " + annotationType
90-
+ " attributed to " + type
91-
+ " Please remove the duplicate annotations and publish a bean to handle your authorization logic.");
92-
}
93-
return AnnotationUtils.findAnnotation(type, annotationType);
86+
return findDistinctAnnotation(type, annotationType);
9487
}
9588

96-
private static <A extends Annotation> boolean hasDuplicate(MergedAnnotations mergedAnnotations,
89+
private static <A extends Annotation> A findDistinctAnnotation(AnnotatedElement annotatedElement,
9790
Class<A> annotationType) {
98-
MergedAnnotation<Annotation> alreadyFound = null;
99-
for (MergedAnnotation<Annotation> mergedAnnotation : mergedAnnotations) {
100-
if (isSynthetic(mergedAnnotation.getSource())) {
101-
continue;
102-
}
103-
104-
if (mergedAnnotation.getType() != annotationType) {
105-
continue;
106-
}
107-
108-
if (alreadyFound == null) {
109-
alreadyFound = mergedAnnotation;
110-
continue;
111-
}
112-
113-
// https://github.com/spring-projects/spring-framework/issues/31803
114-
if (!mergedAnnotation.getSource().equals(alreadyFound.getSource())) {
115-
return true;
116-
}
117-
118-
if (mergedAnnotation.getRoot().getType() != alreadyFound.getRoot().getType()) {
119-
return true;
120-
}
121-
}
122-
return false;
123-
}
124-
125-
private static boolean isSynthetic(Object object) {
126-
if (object instanceof Executable) {
127-
return ((Executable) object).isSynthetic();
128-
}
129-
130-
return false;
91+
MergedAnnotations mergedAnnotations = MergedAnnotations.from(annotatedElement, SearchStrategy.TYPE_HIERARCHY,
92+
RepeatableContainers.none());
93+
94+
List<A> annotations = mergedAnnotations.stream(annotationType)
95+
.map(MergedAnnotation::withNonMergedAttributes)
96+
.map(MergedAnnotation::synthesize)
97+
.distinct()
98+
.toList();
99+
100+
return switch (annotations.size()) {
101+
case 0 -> null;
102+
case 1 -> annotations.get(0);
103+
default -> throw new AnnotationConfigurationException("""
104+
Please ensure there is one unique annotation of type @%s attributed to %s. \
105+
Found %d competing annotations: %s""".formatted(annotationType.getName(), annotatedElement,
106+
annotations.size(), annotations));
107+
};
131108
}
132109

133110
private AuthorizationAnnotationUtils() {

core/src/test/java/org/springframework/security/access/annotation/RequireUserRole.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -25,7 +25,7 @@
2525

2626
@Retention(RetentionPolicy.RUNTIME)
2727
@PreAuthorize("hasRole('USER')")
28-
@RolesAllowed("ADMIN")
28+
@RolesAllowed("USER")
2929
@Secured("USER")
3030
public @interface RequireUserRole {
3131

core/src/test/java/org/springframework/security/authorization/method/AuthorizationAnnotationUtilsTests.java

+113-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,18 +16,26 @@
1616

1717
package org.springframework.security.authorization.method;
1818

19+
import java.lang.annotation.Retention;
20+
import java.lang.annotation.RetentionPolicy;
1921
import java.lang.reflect.Method;
2022
import java.lang.reflect.Proxy;
2123
import java.util.List;
2224

2325
import org.junit.jupiter.api.Test;
2426

27+
import org.springframework.core.annotation.AliasFor;
28+
import org.springframework.core.annotation.AnnotationConfigurationException;
2529
import org.springframework.security.access.prepost.PreAuthorize;
2630

27-
import static org.assertj.core.api.Assertions.assertThatNoException;
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
2833

2934
/**
30-
* Tests for {@link AuthorizationAnnotationUtils}
35+
* Tests for {@link AuthorizationAnnotationUtils}.
36+
*
37+
* @author Josh Cummings
38+
* @author Sam Brannen
3139
*/
3240
class AuthorizationAnnotationUtilsTests {
3341

@@ -37,15 +45,56 @@ void annotationsOnSyntheticMethodsShouldNotTriggerAnnotationConfigurationExcepti
3745
Thread.currentThread().getContextClassLoader(), new Class[] { StringRepository.class },
3846
(p, m, args) -> null);
3947
Method method = proxy.getClass().getDeclaredMethod("findAll");
40-
assertThatNoException()
41-
.isThrownBy(() -> AuthorizationAnnotationUtils.findUniqueAnnotation(method, PreAuthorize.class));
48+
PreAuthorize preAuthorize = AuthorizationAnnotationUtils.findUniqueAnnotation(method, PreAuthorize.class);
49+
assertThat(preAuthorize.value()).isEqualTo("hasRole('someRole')");
4250
}
4351

4452
@Test // gh-13625
4553
void annotationsFromSuperSuperInterfaceShouldNotTriggerAnnotationConfigurationException() throws Exception {
46-
Method method = HelloImpl.class.getMethod("sayHello");
47-
assertThatNoException()
48-
.isThrownBy(() -> AuthorizationAnnotationUtils.findUniqueAnnotation(method, PreAuthorize.class));
54+
Method method = HelloImpl.class.getDeclaredMethod("sayHello");
55+
PreAuthorize preAuthorize = AuthorizationAnnotationUtils.findUniqueAnnotation(method, PreAuthorize.class);
56+
assertThat(preAuthorize.value()).isEqualTo("hasRole('someRole')");
57+
}
58+
59+
@Test
60+
void multipleIdenticalAnnotationsOnClassShouldNotTriggerAnnotationConfigurationException() {
61+
Class<?> clazz = MultipleIdenticalPreAuthorizeAnnotationsOnClass.class;
62+
PreAuthorize preAuthorize = AuthorizationAnnotationUtils.findUniqueAnnotation(clazz, PreAuthorize.class);
63+
assertThat(preAuthorize.value()).isEqualTo("hasRole('someRole')");
64+
}
65+
66+
@Test
67+
void multipleIdenticalAnnotationsOnMethodShouldNotTriggerAnnotationConfigurationException() throws Exception {
68+
Method method = MultipleIdenticalPreAuthorizeAnnotationsOnMethod.class.getDeclaredMethod("method");
69+
PreAuthorize preAuthorize = AuthorizationAnnotationUtils.findUniqueAnnotation(method, PreAuthorize.class);
70+
assertThat(preAuthorize.value()).isEqualTo("hasRole('someRole')");
71+
}
72+
73+
@Test
74+
void competingAnnotationsOnClassShouldTriggerAnnotationConfigurationException() {
75+
Class<?> clazz = CompetingPreAuthorizeAnnotationsOnClass.class;
76+
assertThatExceptionOfType(AnnotationConfigurationException.class)
77+
.isThrownBy(() -> AuthorizationAnnotationUtils.findUniqueAnnotation(clazz, PreAuthorize.class))
78+
.withMessageContainingAll("Found 2 competing annotations:", "someRole", "otherRole");
79+
}
80+
81+
@Test
82+
void competingAnnotationsOnMethodShouldTriggerAnnotationConfigurationException() throws Exception {
83+
Method method = CompetingPreAuthorizeAnnotationsOnMethod.class.getDeclaredMethod("method");
84+
assertThatExceptionOfType(AnnotationConfigurationException.class)
85+
.isThrownBy(() -> AuthorizationAnnotationUtils.findUniqueAnnotation(method, PreAuthorize.class))
86+
.withMessageContainingAll("Found 2 competing annotations:", "someRole", "otherRole");
87+
}
88+
89+
@Test
90+
void composedMergedAnnotationsAreNotSupported() {
91+
Class<?> clazz = ComposedPreAuthAnnotationOnClass.class;
92+
PreAuthorize preAuthorize = AuthorizationAnnotationUtils.findUniqueAnnotation(clazz, PreAuthorize.class);
93+
94+
// If you comment out .map(MergedAnnotation::withNonMergedAttributes) in
95+
// AuthorizationAnnotationUtils.findDistinctAnnotation(), the value of
96+
// the merged annotation would be "hasRole('composedRole')".
97+
assertThat(preAuthorize.value()).isEqualTo("hasRole('metaRole')");
4998
}
5099

51100
private interface BaseRepository<T> {
@@ -82,4 +131,60 @@ public String sayHello() {
82131

83132
}
84133

134+
@Retention(RetentionPolicy.RUNTIME)
135+
@PreAuthorize("hasRole('someRole')")
136+
private @interface RequireSomeRole {
137+
138+
}
139+
140+
@Retention(RetentionPolicy.RUNTIME)
141+
@PreAuthorize("hasRole('otherRole')")
142+
private @interface RequireOtherRole {
143+
144+
}
145+
146+
@RequireSomeRole
147+
@PreAuthorize("hasRole('someRole')")
148+
private static class MultipleIdenticalPreAuthorizeAnnotationsOnClass {
149+
150+
}
151+
152+
private static class MultipleIdenticalPreAuthorizeAnnotationsOnMethod {
153+
154+
@RequireSomeRole
155+
@PreAuthorize("hasRole('someRole')")
156+
void method() {
157+
}
158+
159+
}
160+
161+
@RequireOtherRole
162+
@PreAuthorize("hasRole('someRole')")
163+
private static class CompetingPreAuthorizeAnnotationsOnClass {
164+
165+
}
166+
167+
private static class CompetingPreAuthorizeAnnotationsOnMethod {
168+
169+
@RequireOtherRole
170+
@PreAuthorize("hasRole('someRole')")
171+
void method() {
172+
}
173+
174+
}
175+
176+
@Retention(RetentionPolicy.RUNTIME)
177+
@PreAuthorize("hasRole('metaRole')")
178+
private @interface ComposedPreAuth {
179+
180+
@AliasFor(annotation = PreAuthorize.class)
181+
String value();
182+
183+
}
184+
185+
@ComposedPreAuth("hasRole('composedRole')")
186+
private static class ComposedPreAuthAnnotationOnClass {
187+
188+
}
189+
85190
}

0 commit comments

Comments
 (0)