Search code examples
javaspringspring-bootspring-security

Customize DelegatingReactiveAuthorizationManager to verify all matching rules, instead of giving up on the first that matches


I have an application with a fairly complex authorization model. In the database, I can have entries that specify authorities required for particular endpoints and I load them at the application startup.

Examples (method, pattern, authority):

  1. GET - /api/service1/someEntity - GET_SERVICE1_SOME_ENTITY_AUTHORITY
  2. GET - /api/service2/someEntity - GET_SERVICE2_SOME_ENTITY_AUTHORITY
  3. GET - /api/service1/** - GET_SERVICE1_ANYTHING_AUTHORITY
  4. null - /** - DO_ANYTHING (admin)

Before I load these rules, I sort them from most specific to most general, so they end up in exactly the same order as above. I load them like that (for each in the sorted order):

http.authorizeExchange()
    .pathMatchers(permission.getMethod(), permission.getPattern())
    .hasAuthority(permission.getAuthority());

Now the problem is that when I have user: ADMIN with authority DO_ANYTHING, it will actually not be able to do anything, because of the way how spring security works.

Let's say the admin wants to access /api/service1/someEntity. Spring will match rule no.1 and check if admin has authority GET_SERVICE1_SOME_ENTITY_AUTHORITY and it will turn out that he doesn't. At this point, Spring will deny access. But what I want it to do is to check other matching rules (numbers 3 and 4).

I found the code responsible for this:

public final class DelegatingReactiveAuthorizationManager implements ReactiveAuthorizationManager<ServerWebExchange> {
// ...
    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, ServerWebExchange exchange) {
        return Flux.fromIterable(this.mappings).concatMap((mapping) -> mapping.getMatcher().matches(exchange)
                .filter(MatchResult::isMatch).map(MatchResult::getVariables).flatMap((variables) -> {
                    logger.debug(LogMessage.of(() -> "Checking authorization on '"
                            + exchange.getRequest().getPath().pathWithinApplication() + "' using "
                            + mapping.getEntry()));
                    return mapping.getEntry().check(authentication, new AuthorizationContext(exchange, variables));
                })).next().defaultIfEmpty(new AuthorizationDecision(false));
    }
// ...
}

Unfortunately I don't know how to replace this implementation with mine.

tldr: I want spring security to verify all security rules matching the request path, instead of denying access on the first one that matches.


Solution

  • Ok, I figured out a solution, a little bit hacky, but it does what I need.

    I've created my own implementation of ReactiveAuthorizationManager<ServerWebExchange>, which is almost the same as Spring's DelegatingReactiveAuthorizationManager, but has one additional filter, which does the job:

    public class MyAuthorizationManager implements ReactiveAuthorizationManager<ServerWebExchange> {
    
        private final List<ServerWebExchangeMatcherEntry<ReactiveAuthorizationManager<AuthorizationContext>>> mappings;
    
        @Override
        public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, ServerWebExchange exchange) {
            return Flux.fromIterable(this.mappings)
                       .concatMap((mapping) -> mapping.getMatcher()
                                                      .matches(exchange)
                                                      .filter(ServerWebExchangeMatcher.MatchResult::isMatch)
                                                      .map(ServerWebExchangeMatcher.MatchResult::getVariables)
                                                      .flatMap((variables) -> {
                                                          log.debug(LogMessage.of(() -> "Checking authorization on '"
                                                                  + exchange.getRequest()
                                                                            .getPath()
                                                                            .pathWithinApplication() + "' using "
                                                                  + mapping.getEntry()).toString());
                                                          return mapping.getEntry()
                                                                        .check(authentication, new AuthorizationContext(exchange, variables));
                                                      }).filter(AuthorizationDecision::isGranted) /* <- this is what I needed */)
                       .next()
                       .defaultIfEmpty(new AuthorizationDecision(false));
        }
    }
    

    and this is how I registered it:

     @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
            
            List<Permission> permissions = repo.getPermissions()
    
            MyAuthorizationManager.Builder authBuilder = MyAuthorizationManager.builder();
    
            permissions.forEach(permission -> {
                // I create the mappings here, so matcher + auth manager for that matcher
                authBuilder.add(
                        ServerWebExchangeMatchers.pathMatchers(permission.getMethod(), permission.getPattern()), 
                        AuthorityReactiveAuthorizationManager.hasAuthority(permission.getAuthority())
                );
            });
            
            http.authorizeExchange().anyExchange().permitAll()
                .and()
                .httpBasic()
                .and()
                .formLogin().disable()
                .anonymous().disable();
    
            // it will be before the default authorization filter, bu actually it doesn't really matter
            http.addFilterBefore(new AuthorizationWebFilter(authBuilder.build()), SecurityWebFiltersOrder.AUTHORIZATION);
    
            return http.build();
    }
    

    I have left .authorizeExchange().anyExchange().permitAll() because it sets some default things like entry point, exception web filter and I didn't want to copy more code and totally give up the default manager.