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

100 lines
4.5 KiB
Markdown

[← 07 · ordering and transactions](07-ordering-and-transactions.md) · [chapter index](README.md) · [next: the audit checklist →](09-audit-checklist.md)
# 08 · Meta-annotations, templates, class-level rules, ambiguity
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo8MetaAnnotations`
Output: [`output/demo8.txt`](output/demo8.txt)
Source: [`Demo8MetaAnnotations.java`](../src/main/java/com/ankurm/methodsec/Demo8MetaAnnotations.java)
## Naming a rule
```java
@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
```java
@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`](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:
```java
@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
```java
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`](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](07-ordering-and-transactions.md) · [chapter index](README.md) · [next: the audit checklist →](09-audit-checklist.md)