1
0

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:
2026-08-25 02:01:29 +00:00
parent 9f950bffa9
commit 5e9e7f1b12
65 changed files with 4088 additions and 119 deletions

View File

@@ -0,0 +1,121 @@
[← 06 · denial handling](06-denied-handling.md) · [chapter index](README.md) · [next: meta-annotations →](08-meta-annotations.md)
# 07 · Ordering, and `@PostAuthorize` vs `@Transactional`
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo6InterceptorOrder`
Output: [`output/demo6.txt`](output/demo6.txt)
Source: [`Demo6InterceptorOrder.java`](../src/main/java/com/ankurm/methodsec/Demo6InterceptorOrder.java)
## The numbers, read from the enum
`AuthorizationInterceptorsOrder`, printed from the running JVM rather than transcribed:
| Constant | `getOrder()` |
|---|---|
| `FIRST` | `-2147483648` |
| `PRE_FILTER` | `100` |
| `PRE_AUTHORIZE` | `200` |
| `SECURED` | `300` |
| `JSR250` | `400` |
| `SECURE_RESULT` | `450` |
| `POST_AUTHORIZE` | `500` |
| `POST_FILTER` | `600` |
| `LAST` | `2147483647` |
And the advisor beans `@EnableMethodSecurity` actually registers:
```
100 preFilterAuthorizationMethodInterceptor
200 preAuthorizeAuthorizationMethodInterceptor
450 authorizeReturnObjectMethodInterceptor
500 postAuthorizeAuthorizationMethodInterceptor
600 postFilterAuthorizationMethodInterceptor
```
## Lower order means further out — which flips for "after" advice
Lower order = higher precedence = further *out* in the chain. For the two "before"
annotations that reads the obvious way: `@PreFilter` (100) runs before `@PreAuthorize` (200).
For the two "after" annotations it reads backwards. `@PostAuthorize` (500) sits **further out**
than `@PostFilter` (600), so on the way back out `@PostFilter` finishes first and
`@PostAuthorize` evaluates `returnObject` against the **already-filtered** value.
Two methods, identical except for the expected size, prove it:
```java
@PostAuthorize("returnObject.size() == 3")
@PostFilter("filterObject != 'c'")
public List<String> expectsThree(List<String> in) { return in; }
```
```
@PostAuthorize returnObject.size() == 3 DENIED
@PostAuthorize returnObject.size() == 2 ALLOWED -> [a, b]
```
Three elements went in, the method returned three, and `@PostAuthorize` saw two. If you have
both annotations on one method, `returnObject` is not what the method returned.
## The transaction problem
Spring's `@Transactional` advisor defaults to `Ordered.LOWEST_PRECEDENCE` (`2147483647`),
which is larger than every security order above. So **security wraps transactions**: the
transaction commits, and only then does `@PostAuthorize` decide the caller may not see the
result.
```java
@Transactional
@PostAuthorize("returnObject.owner == authentication.name")
public Account recordAndReturn(String owner) {
this.jdbc.update("insert into audit(owner) values (?)", owner);
return new Account(1, owner, 100);
}
```
Against a real H2 database, as a caller who is denied:
```
rows before : 0
recordAndReturn("bob") @PostAuthorize DENIED -> AuthorizationDeniedException
rows after the denial : 1
```
The row is there. The caller got a 403 and the write happened anyway.
## Fixing it
Move the transaction advisor outside the security advisor, so the
`AuthorizationDeniedException` propagates through it and triggers the normal
rollback-on-`RuntimeException` rule:
```java
@EnableTransactionManagement(order = Integer.MIN_VALUE)
```
Same run, same denial:
```
rows before : 0
recordAndReturn("bob") @PostAuthorize DENIED -> AuthorizationDeniedException
rows after the denial : 0
```
`@EnableMethodSecurity(offset = ...)` shifts every security interceptor by a fixed amount and
gets you to the same place from the other side. Use whichever you can reason about later;
`@EnableTransactionManagement(order = ...)` names the thing you are actually moving.
**But prefer not to need it.** Rolling back on an authorization failure means you have already
done the work and are undoing it, and rollback is not universal — a message you published, a
file you wrote, an outbound HTTP call are all still gone. The reference documentation's advice
holds: do not combine `@PostAuthorize` with a method that writes. Authorize on the way in with
`@PreAuthorize` and arguments, or read with `@PostAuthorize` and write from a separate method.
## While you are here: `@Transactional` has the same two traps
Everything in [chapter 03](03-self-invocation.md) and
[chapter 04](04-non-proxyable-methods.md) applies to `@Transactional` unchanged — same proxy,
same overriding rules. A `private @Transactional` method is exactly as inert as a `private
@PreAuthorize` one. If you find one, look for the other.
[← 06 · denial handling](06-denied-handling.md) · [chapter index](README.md) · [next: meta-annotations →](08-meta-annotations.md)