Mastodon

Using Meta-Annotations in Spring MVC Controllers

I saw a nice best practice recently that I want to share. It’s about how to handle @PreAuthorize annotations in Spring controllers. An architect proposed to use meta annotations and ArchUnit to check that only those meta annotations are used.

Meta Annotation PermitAdmin

A Spring MVC controller or the methods within this class can be annotated with @PreAuthorize to only allow requests with a certain role:

@PreAuthorize("hasRole('ROLE_ADMIN')")
@RestController
@RequestMapping("/admin")
public class AdminController {
...
}

However, the string ROLE_ADMIN in the SPEL expression within @PreAuthorize should be extracted to a constant so it can be configured easily later on without having to change every controller. This is solved with a meta annotation like:

import org.springframework.security.access.prepost.PreAuthorize;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Declares a method or type as accessible by role Admin.
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ROLE_ADMIN')")
public @interface PermitAdmin {

}

Now, the controller can be annotated like this:

@PermitAdmin
@RestController
@RequestMapping("/admin")
public class AdminController {
...
}

This approach is also mentioned in the article Introduction to Spring Method Security on Baeldung.

Multiple annotations for multiple roles are possible, but may have to be handled in the corresponding filter classes like OncePerRequestFilter.

Checking Usage of Meta Annotations With ArchUnit

To make sure that no one uses the @PreAuthorize annotation in controllers directly, the following rules can be implemented with ArchUnit:

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RestController;
import your.project.PermitAdmin;

import java.lang.annotation.Target;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@SuppressWarnings("unused")
@AnalyzeClasses(packages = "your.project", importOptions = {ImportOption.DoNotIncludeTests.class, ImportOption.DoNotIncludeJars.class})
public class ArchitecturePermissionsTest {
    
	@ArchTest
    static final ArchRule noSpringSecurityAnnotations = noClasses()
        .that().areNotAnnotatedWith(Target.class)
        .should().beAnnotatedWith(PreAuthorize.class)
        .because("meta-annotations should be used");

    @ArchTest
    static final ArchRule everyControllerMethodHasSecurityAnnotated = methods()
        .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
        .should().beMetaAnnotatedWith(PermitAdmin.class)
        .orShould().beDeclaredInClassesThat().areMetaAnnotatedWith(PermitAdmin.class)
        .because("every accessible method should define permissions for usage");
}

Again, these two approaches stem from one architect on the project and were new to me. I like this and will apply this to future projects.

(Image Public Domain, https://pxhere.com/de/photo/985038)