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
105 lines
4.6 KiB
Markdown
105 lines
4.6 KiB
Markdown
[← 05 · filtering](05-filtering.md) · [chapter index](README.md) · [next: ordering and transactions →](07-ordering-and-transactions.md)
|
|
|
|
# 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`](output/demo7.txt)
|
|
Source: [`Demo7DeniedHandling.java`](../src/main/java/com/ankurm/methodsec/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:
|
|
|
|
```java
|
|
@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:
|
|
|
|
```java
|
|
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](04-non-proxyable-methods.md)
|
|
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`:
|
|
|
|
```java
|
|
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 `authorizeReturnObject` advisor is registered at order **450**
|
|
(`AuthorizationInterceptorsOrder.SECURE_RESULT`) whether or not anything in your application
|
|
uses `@AuthorizeReturnObject` — visible in [`output/demo6.txt`](output/demo6.txt).
|
|
- At class level it proxies *every* return value, including `String` and boxed primitives.
|
|
Publish `AuthorizationAdvisorProxyFactory.TargetVisitor.defaultsSkipValueTypes()` if you go
|
|
that route.
|
|
|
|
[← 05 · filtering](05-filtering.md) · [chapter index](README.md) · [next: ordering and transactions →](07-ordering-and-transactions.md)
|