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
100 lines
4.5 KiB
Markdown
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)
|