1
0
Files
spring-security-demo/method-security/docs/08-meta-annotations.md
asmhatre 5e9e7f1b12 Split into per-article modules and add the method-security module
Moves the existing virtual-thread/context-propagation project into
context-propagation/ and adds method-security/ for the Spring Security 7
method-security article: nine runnable demos, fourteen assertions, and every
transcript the article quotes, regenerated by scripts/run-all.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
2026-08-25 02:01:29 +00:00

4.5 KiB

← 07 · ordering and transactions · chapter index · next: the audit checklist →

08 · Meta-annotations, templates, class-level rules, ambiguity

Run: java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo8MetaAnnotations Output: output/demo8.txt Source: Demo8MetaAnnotations.java

Naming a rule

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin { }

@IsAdmin now works anywhere @PreAuthorize("hasRole('ADMIN')") would, with no configuration. The value is not brevity — it is that the rule exists in one place, so changing it is an edit rather than a grep.

Templated meta-annotations, and a documentation correction

@PreAuthorize("hasRole('{value}')")
public @interface HasRole { String value(); }

The reference documentation says you must publish an AnnotationTemplateExpressionDefaults bean for {value} to be substituted. In Spring Security 7.1.1 you do not. output/demo8.txt shows @HasRole("ADMIN") discriminating correctly between an admin and a non-admin in a context with no such bean.

The reason is in PreAuthorizeExpressionAttributeRegistry, which initialises its scanner as SecurityAnnotationScanners.requireUnique(PreAuthorize.class) — and that overload constructs new AnnotationTemplateExpressionDefaults() internally. The bean is autowired @Autowired(required = false) and only replaces that default. Its single knob is setIgnoreUnknown(false), which turns an unrecognised placeholder into an error instead of leaving it in the expression. That is worth publishing the bean for; making templates work at all is not.

One caveat, read from the source rather than executed here: SecurityAnnotationScanners.requireUnique memoises scanners in a static map keyed by annotation type. Within a single JVM the first configuration for a given annotation type is the one that sticks — relevant if you run several differently-configured contexts in one test JVM.

Multi-value templates need the quotes inside the attribute, which is as awkward as it looks:

@PreAuthorize("hasAnyRole({roles})")
public @interface HasAnyRole { String[] roles(); }

@HasAnyRole(roles = { "'USER'", "'ADMIN'" })

Class level

@PreAuthorize on the class applies to every method. A method-level @PreAuthorize replaces it rather than adding to it — the nearest declaration wins:

inherited from the class  (needs ADMIN)        DENIED
method-level overrides it (needs USER)         ALLOWED

Different annotation types are ANDed. A class-level @PreAuthorize and a method-level @PostAuthorize both have to pass.

The trap is the override. Adding @PreAuthorize("hasRole('USER')") to one method of an @PreAuthorize("hasRole('ADMIN')") class looks like a tightening and is a widening.

Two interfaces, two rules

public interface ReadsAsUser  { @PreAuthorize("hasRole('USER')")  String read(); }
public interface ReadsAsAdmin { @PreAuthorize("hasRole('ADMIN')") String read(); }
public class Ambiguous implements ReadsAsUser, ReadsAsAdmin { public String read() { ... } }

Spring Security refuses to pick one:

AnnotationConfigurationException: Please ensure there is one unique annotation of type
[interface ...PreAuthorize] attributed to public abstract java.lang.String ...ReadsAsUser.read().
Found 2 competing annotations: [@PreAuthorize("hasRole('USER')"), @PreAuthorize("hasRole('ADMIN')")]

Correction to the common description: this is not a startup failure. The context refreshes, the bean is proxied, and the exception appears the first time the method is called. Guidance that says "if a class inherits the same annotation from two interfaces, startup fails" describes a friendlier framework than the one you are running. Verified in output/demo8.txt.

The fix is to annotate the implementation method, which is the nearest declaration and wins outright. The broader lesson is to put authorization rules in one layer and keep them there. Repeating the same annotation twice on one method is also unsupported — combine with and/or inside a single expression, or delegate to a bean.

← 07 · ordering and transactions · chapter index · next: the audit checklist →