1
0
Files
spring-security-demo/method-security/docs/07-ordering-and-transactions.md
asmhatre 5e9e7f1b12 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
2026-08-25 02:01:29 +00:00

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 →