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
← 05 · filtering · chapter index · next: ordering and transactions →
06 · Denial: what is thrown, and how to change it
Run: java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo7DeniedHandling
Output: output/demo7.txt
Source: Demo7DeniedHandling.java
The exception
thrown -> org.springframework.security.authorization.AuthorizationDeniedException
is AccessDeniedException -> true
is AuthorizationDeniedException -> true
carries an AuthorizationResult -> ExpressionAuthorizationDecision granted=false
Documentation and older posts say "throws AccessDeniedException", and code written against
that still catches it. But the concrete type is AuthorizationDeniedException, and it carries
the AuthorizationResult that explains the denial — for a SpEL rule, an
ExpressionAuthorizationDecision that knows which expression failed. That is the difference
between an audit log line saying "access denied" and one saying which rule denied it.
Returning something instead of throwing
@HandleAuthorizationDenied names a MethodAuthorizationDeniedHandler bean, which gets the
MethodInvocation and the AuthorizationResult and returns a value in place of the throw:
@PreAuthorize("hasRole('FINANCE')")
@HandleAuthorizationDenied(handlerClass = MaskingHandler.class)
public String maskedBalance() { return "1,204,993.22"; }
maskedBalance() (alice, no ROLE_FINANCE) ALLOWED -> ***masked***
maskedList() (alice, no ROLE_FINANCE) ALLOWED -> []
maskedBalance() (cfo, has ROLE_FINANCE) ALLOWED -> 1,204,993.22
The returned value must be assignable to the method's declared return type, so a handler shared
across methods has to inspect it — the demo's handler returns List.of() for a List return
type and a masked string otherwise. A handler that gets this wrong fails with a
ClassCastException at the call site, which is worse than the denial it was replacing.
Use this where a partial answer is genuinely correct — a masked field on a shared DTO, an empty list for a section the user cannot see. Do not use it to make an authorization failure invisible to your own logs.
@AuthorizeReturnObject
Moves the check from the method that returns an object onto the object's own accessors:
public class Customer {
public String getName() { return this.name; }
@PreAuthorize("hasAuthority('pii:read')")
public String getEmail() { return this.email; }
}
@AuthorizeReturnObject
public Customer findCustomer(String name) { ... }
returned instance -> ...Customer$$SpringCGLIB$$0
customer.getName() (no authority needed) ALLOWED -> alice
customer.getEmail() (needs 'pii:read') DENIED -> AuthorizationDeniedException
The returned object is CGLIB-proxied, which means chapter 04
applies to it in full: the class cannot be final, so a record will not work, and a final
getter is not advised. The Customer in this demo is deliberately a plain class for exactly
that reason.
The same thing without the annotation, through the container's AuthorizationProxyFactory:
AuthorizationProxyFactory factory = ctx.getBean(AuthorizationProxyFactory.class);
Customer wrapped = factory.proxy(raw); // generic, no cast needed
raw.getEmail() (unproxied object) ALLOWED -> alice@example.com
wrapped.getEmail() (proxied object) DENIED -> AuthorizationDeniedException
Package correction: AuthorizationProxyFactory lives in
org.springframework.security.authorization, not org.springframework.security.authorization.method
where the reference documentation places it. The implementation,
AuthorizationAdvisorProxyFactory, is in …authorization.method; the interface is one package
up. proxy(T) is generic and returns T.
Two more things worth knowing before you reach for it:
- The
authorizeReturnObjectadvisor is registered at order 450 (AuthorizationInterceptorsOrder.SECURE_RESULT) whether or not anything in your application uses@AuthorizeReturnObject— visible inoutput/demo6.txt. - At class level it proxies every return value, including
Stringand boxed primitives. PublishAuthorizationAdvisorProxyFactory.TargetVisitor.defaultsSkipValueTypes()if you go that route.
← 05 · filtering · chapter index · next: ordering and transactions →