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
This commit is contained in:
99
method-security/docs/08-meta-annotations.md
Normal file
99
method-security/docs/08-meta-annotations.md
Normal file
@@ -0,0 +1,99 @@
|
||||
[← 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)
|
||||
Reference in New Issue
Block a user