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
122 lines
4.6 KiB
Markdown
122 lines
4.6 KiB
Markdown
[← 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)
|