Describe the bug
Declaring RoleHierarchy in a @Bean behave differently when declared manually to AuthorityAuthorizationManager.
I'm using spring-boot-3.1.4
To Reproduce
Please refer to my repo for full example.
I have the following security configuration
@EnableMethodSecurity
@EnableWebSecurity(debug = false)
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.anonymous(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(httpReq -> httpReq.anyRequest().access(haveReadPermission()))
.build();
}
// see below for the configuration for RoleHierarchy and AuthorityAuthorizationManager
}
And the following test
@WebMvcTest(MeController.class)
@Import({ SecurityConfig.class})
class MeControllerTests {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = { "ADMIN" }, authorities = { "ROLE_ADMIN" } )
void test1() throws Exception {
this.mockMvc
.perform(MockMvcRequestBuilders
.get("/me"))
.andDo(MockMvcResultHandlers.print());
}
}
Pass Scenario
Given the following configuration; manually passing in role hierarchy, the test will pass
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String roleHierarchyFromMap = """
ROLE_ADMIN > ROLE_STAFF
ROLE_STAFF > ROLE_USER
ROLE_STAFF > ROLE_GUEST
""";
roleHierarchy.setHierarchy(roleHierarchyFromMap);
return roleHierarchy;
}
private AuthorizationManager<RequestAuthorizationContext> haveReadPermission() {
AuthorityAuthorizationManager<RequestAuthorizationContext> authority = AuthorityAuthorizationManager.hasAnyAuthority("ROLE_USER");
authority.setRoleHierarchy(this.roleHierarchy());
return authority;
}
click to view test result
MockHttpServletRequest:
HTTP Method = GET
Request URI = /me
Parameters = {}
Headers = []
Body = null
Session Attrs = {}
Handler:
Type = com.bwgjoseph.springsecurityrolehierarchybug.MeController
Method = com.bwgjoseph.springsecurityrolehierarchybug.MeController#me(MyUserDetails)
Async:
Async started = false
Async result = null
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = application/json
Body = {"username":"rob","email":"rob@google.com","roles":["ROLE_ADMIN"],"authorities":[{"authority":"ROLE_ADMIN"}],"enabled":true,"password":"N/A","credentialsNonExpired":true,"accountNonExpired":true,"accountNonLocked":true}
Forwarded URL = null
Redirected URL = null
Cookies = []
Fail Scenario
With the following configuration; configure RoleHierarchy as @Bean and remove manually passing in role hierarchy. The test will fail.
@Bean // enable this
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String roleHierarchyFromMap = """
ROLE_ADMIN > ROLE_STAFF
ROLE_STAFF > ROLE_USER
ROLE_STAFF > ROLE_GUEST
""";
roleHierarchy.setHierarchy(roleHierarchyFromMap);
return roleHierarchy;
}
private AuthorizationManager<RequestAuthorizationContext> haveReadPermission() {
AuthorityAuthorizationManager<RequestAuthorizationContext> authority = AuthorityAuthorizationManager.hasAnyAuthority("ROLE_USER");
// authority.setRoleHierarchy(this.roleHierarchy()); // remove this
return authority;
}
click to view test result
MockHttpServletRequest:
HTTP Method = GET
Request URI = /me
Parameters = {}
Headers = []
Body = null
Session Attrs = {}
Handler:
Type = null
Async:
Async started = false
Async result = null
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 403
Error message = Forbidden
Headers = [X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
Expected behavior
I expect that AuthorityAuthorizationManager should have the same behavior whether RoleHierarchy is defined as a @Bean or manually through the setter.
This issue - https://github.com/spring-projects/spring-security/issues/13188 - seem to suggest that this feature should be in working state after spring-boot 3.1.0
Possible related issue
- https://github.com/spring-projects/spring-security/issues/12473
- https://github.com/spring-projects/spring-security/issues/13188
- https://github.com/vrudas/spring-framework-examples/issues/101
Sample
Click the link to a GitHub repository with a minimal, reproducible sample.
Comment From: marcusdacoregio
Hi, @bwgjoseph, thanks for the report.
13188 make it so when you use hasAuthority, hasRole, and its variants, inside authorizeHttpRequests, Spring Security should consider the RoleHierarchy bean. Since you are defining your own AuthorizationManager (not using authorizeHttpRequests) you must set the field using the provided setter.
You can make your RoleHierarchy a bean and inject it into the AuthorizationManager bean method definition if you want:
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
String roleHierarchyFromMap = """
ROLE_ADMIN > ROLE_STAFF
ROLE_STAFF > ROLE_USER
ROLE_STAFF > ROLE_GUEST
""";
roleHierarchy.setHierarchy(roleHierarchyFromMap);
return roleHierarchy;
}
private AuthorizationManager<RequestAuthorizationContext> haveReadPermission(RoleHierarchy roleHierarchy) {
AuthorityAuthorizationManager<RequestAuthorizationContext> authority = AuthorityAuthorizationManager.hasAnyAuthority("ROLE_USER");
authority.setRoleHierarchy(roleHierarchy);
return authority;
}
Comment From: bwgjoseph
Hi @marcusdacoregio,
Thanks for the clarification!
In that case, then is it possible to consider having AuthorizationManager also infer directly from the bean than having to set it manually?
As I have a number AuthorizationManager methods (e.g haveReadPermission, haveWritePermission, ...), having to keep re-declaring it is a little bit tedious.
Thanks!
Comment From: marcusdacoregio
You can consider creating a factory to do that work for you, something like:
@Component
class AuthorizationManagerFactory {
private final RoleHierarchy roleHierarchy;
// constructor
public AuthorizationManager<RequestAuthorizationContext> haveReadPermission() {
AuthorityAuthorizationManager<RequestAuthorizationContext> authority = AuthorityAuthorizationManager.hasAnyAuthority("ROLE_USER");
authority.setRoleHierarchy(this.roleHierarchy);
return authority;
}
// ... other methods
}
Then, you can:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthorizationManagerFactory factory) throws Exception {
return http
// ...
.authorizeHttpRequests(httpReq -> httpReq.anyRequest().access(factory.haveReadPermission()))
.build();
}
Comment From: bwgjoseph
@marcusdacoregio
Sorry, I meant the tedious is having to keep calling the setRoleHierarchy with each of the method. As I'm not familiar enough with this, I'm not sure if it's possible to have AuthorityAuthorizationManager inferred from the Bean like how authorizeHttpRequests is done.
public AuthorizationManager<RequestAuthorizationContext> haveReadPermission() {
AuthorityAuthorizationManager<RequestAuthorizationContext> authority = AuthorityAuthorizationManager.hasAnyAuthority("ROLE_USER");
// not required to set, inferred from the bean hierarchy
// authority.setRoleHierarchy(this.roleHierarchy);
return authority;
}
Although, it's not really a big issue to keep re-declaring per method. Just wanted to know if the experience can be better. So I can do something along this line
@Bean
public RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
// omitted
return roleHierarchy;
}
public AuthorizationManager<RequestAuthorizationContext> haveReadPermission() {
return AuthorityAuthorizationManager.hasAnyAuthority("ROLE_USER");
}
public AuthorizationManager<RequestAuthorizationContext> haveWritePermission() {
return AuthorityAuthorizationManager.hasAnyAuthority("ROLE_MODERATOR");
}