1
0

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:
2026-08-25 02:01:29 +00:00
parent 9f950bffa9
commit 5e9e7f1b12
65 changed files with 4088 additions and 119 deletions

View File

@@ -0,0 +1,104 @@
[← 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)