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
4.6 KiB
← 06 · denial handling · chapter index · next: meta-annotations →
07 · Ordering, and @PostAuthorize vs @Transactional
Run: java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo6InterceptorOrder
Output: output/demo6.txt
Source: 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:
@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.
@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:
@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 and
chapter 04 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 · chapter index · next: meta-annotations →