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:
121
method-security/docs/07-ordering-and-transactions.md
Normal file
121
method-security/docs/07-ordering-and-transactions.md
Normal 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)
|
||||
Reference in New Issue
Block a user