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,76 @@
[← chapter index](README.md) · [next: the SpEL reference →](02-spel-reference.md)
# 01 · How method security actually runs
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo1AnnotationsInAction`
Output: [`output/demo1.txt`](output/demo1.txt)
Source: [`Demo1AnnotationsInAction.java`](../src/main/java/com/ankurm/methodsec/Demo1AnnotationsInAction.java)
## The one-sentence model
`@PreAuthorize` is not a keyword the JVM understands. It is an annotation that a Spring AOP
**advisor** matches, on a **proxy** that wraps your bean, intercepting calls that arrive from
outside. Every trap in this repository follows from that sentence.
## What `@EnableMethodSecurity` switches on
Nothing happens without it. Spring Boot's security auto-configuration does not enable method
security; the annotation is yours to add. Its attributes, read out of the `AnnotationDefault`
attributes in `spring-security-config-7.1.1.jar` rather than from documentation:
| Attribute | Default | Effect |
|---|---|---|
| `prePostEnabled` | `true` | `@PreAuthorize`, `@PostAuthorize`, `@PreFilter`, `@PostFilter` |
| `securedEnabled` | `false` | `@Secured` |
| `jsr250Enabled` | `false` | `@RolesAllowed`, `@PermitAll`, `@DenyAll` |
| `proxyTargetClass` | `false` | Force CGLIB even when the bean implements interfaces |
| `mode` | `AdviceMode.PROXY` | The alternative is `ASPECTJ`, which sidesteps chapters 03 and 04 entirely |
| `offset` | `0` | Shifts every security advisor's order by this amount |
Note the third row. `@Secured("ROLE_ADMIN")` and `@RolesAllowed("ADMIN")` compile, look
correct in review, and do nothing at all until you switch them on. That is the zeroth silent
failure, and unlike the two the article is named for it takes one attribute to fix.
There is no `order` attribute, despite what several guides say. It is `offset`, and it moves
all the interceptors together — see [chapter 07](07-ordering-and-transactions.md).
## The call path
```
caller
└─ proxy (CGLIB subclass, or JDK dynamic proxy)
└─ @PreFilter advisor, order 100 mutates the argument collection
└─ @PreAuthorize advisor, order 200 evaluates SpEL, throws or proceeds
└─ @PostAuthorize advisor, order 500
└─ @PostFilter advisor, order 600
└─ your method body
```
Each advisor builds a `MethodSecurityExpressionRoot` over the `Authentication` from
`SecurityContextHolder` plus the `MethodInvocation`, hands it to a
`MethodSecurityExpressionHandler`, and evaluates the annotation's expression against it.
## What a denial looks like
`Demo1` runs the same service as three identities. Two details from
[`output/demo1.txt`](output/demo1.txt) are worth internalising:
- With an `Authentication` present but insufficient, you get `AuthorizationDeniedException`.
- With **no** `Authentication` at all you get `AuthenticationCredentialsNotFoundException`
instead — a different exception, from a different place, which an `@ExceptionHandler` written
only for `AccessDeniedException` will not catch. `@PermitAll` still returns normally, because
it never asks for the `Authentication`.
In a servlet application both are normally translated by `ExceptionTranslationFilter`, so you
see 403 and 401 respectively. Outside a request — a scheduled job, a message listener, a test —
nothing translates them and they surface raw.
## Where the `Authentication` comes from
`SecurityContextHolder`, on the calling thread. If the call happens on a thread that never
received the context, method security does not fail open; it throws
`AuthenticationCredentialsNotFoundException`. Getting the context onto that thread is a
separate topic with its own module in this repository — see
[`../context-propagation/`](../context-propagation/README.md).
[← chapter index](README.md) · [next: the SpEL reference →](02-spel-reference.md)

View File

@@ -0,0 +1,117 @@
[← 01 · how it runs](01-how-method-security-runs.md) · [chapter index](README.md) · [next: self-invocation →](03-self-invocation.md)
# 02 · The SpEL reference
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo4SpelReference`
Output: [`output/demo4.txt`](output/demo4.txt), [`output/demo9-with-parameters.txt`](output/demo9-with-parameters.txt), [`output/demo9-without-parameters.txt`](output/demo9-without-parameters.txt)
Source: [`Demo4SpelReference.java`](../src/main/java/com/ankurm/methodsec/Demo4SpelReference.java), [`Demo9ParameterNames.java`](../src/main/java/com/ankurm/methodsec/Demo9ParameterNames.java)
Every row below is a real annotated method on a real proxied bean in `Demo4`, invoked as two
different users. Nothing here is transcribed from documentation.
## Predicates
| Expression | Meaning |
|---|---|
| `hasRole('ADMIN')` | authority `ROLE_ADMIN` (prefix configurable) |
| `hasAnyRole('A','B')` | any of them |
| `hasAllRoles('A','B')` | all of them |
| `hasAuthority('report:read')` | the authority string, verbatim, no prefix |
| `hasAnyAuthority(..)` / `hasAllAuthorities(..)` | as above |
| `isAuthenticated()` | authenticated and not anonymous |
| `isFullyAuthenticated()` | authenticated, not anonymous, not remember-me |
| `isRememberMe()` / `isAnonymous()` | the two it excludes |
| `permitAll` / `denyAll` | constants; also callable as `permitAll()` / `denyAll()` |
| `hasPermission(target, permission)` | delegates to a `PermissionEvaluator` bean |
| `hasPermission(id, 'type', permission)` | the id/type overload |
`hasAllRoles` and `hasAllAuthorities` are easy to miss — they are on
`SecurityExpressionRoot` alongside the `any` variants.
## The root object
`MethodSecurityExpressionRoot` (package-private, extends
`SecurityExpressionRoot<MethodInvocation>`) exposes exactly:
| Reference | Available in | What it is |
|---|---|---|
| `authentication` | everywhere | the `Authentication` |
| `principal` | everywhere | `authentication.getPrincipal()` |
| `returnObject` | `@PostAuthorize`, `@PostFilter` | the value the method returned |
| `filterObject` | `@PreFilter`, `@PostFilter` | the element currently being tested |
| `#root.this` | everywhere | the target object |
| `read`, `write`, `create`, `delete`, `admin` | everywhere | `String` constants for `hasPermission(..)` |
There is **no positional access to arguments**. `#root.args[0]` fails with
```
EL1008E: Property or field 'args' cannot be found on object of type
'org.springframework.security.access.expression.method.MethodSecurityExpressionRoot'
```
Arguments are bound by name, which brings us to the flag.
## `#parameterName` needs `-parameters`
`@PreAuthorize("#owner == authentication.name")` resolves `#owner` through a
`ParameterNameDiscoverer`. Java only keeps parameter names in the class file when `javac` is
given `-parameters`. Without it the name is `arg0`, `#owner` resolves to nothing, and the
comparison is false.
The same class, same expression, two compilations —
[`demo9-with-parameters.txt`](output/demo9-with-parameters.txt) versus
[`demo9-without-parameters.txt`](output/demo9-without-parameters.txt):
```
compiled with -parameters : true compiled with -parameters : false
byParameterName param[0] : owner byParameterName param[0] : arg0
alice → #owner == name : ALLOWED alice → #owner == name : DENIED
```
It fails closed, which is the good direction, but it fails **silently** — the rule being
enforced is not the rule you wrote. Spring Boot's Maven and Gradle plugins set `-parameters`
for you; a hand-rolled build, a shaded jar, or a module compiled by a different toolchain may
not. `@P("alias")` from `org.springframework.security.core.parameters` does not depend on the
flag, because the name lives in the annotation.
## Beans and types
`@beanName.method(...)` resolves a bean from the context, which is the cleanest way to move a
non-trivial rule out of a string literal and into testable code:
```java
@PreAuthorize("@accountPolicy.canRead(authentication, #id)")
public Account read(long id) { ... }
```
`T(java.time.LocalDate).now().year >= 2020` works too. It is legal, and it is a warning sign:
an expression that needs a type reference is an expression that wants to be a bean method.
## Role prefix and hierarchy, in 7.1
Both moved. `AbstractSecurityExpressionHandler.setRoleHierarchy(..)` is **deprecated** in
Spring Security 7.1 — the compiler says so, which is how this module found out. The current
knob is an `AuthorizationManagerFactory`:
```java
@Bean
static AuthorizationManagerFactory<MethodInvocation> authorizationManagerFactory() {
DefaultAuthorizationManagerFactory<MethodInvocation> factory = new DefaultAuthorizationManagerFactory<>();
factory.setRoleHierarchy(RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("USER")
.role("USER").implies("GUEST")
.build());
factory.setRolePrefix("ROLE_");
return factory;
}
```
`PrePostMethodSecurityConfiguration` autowires both an `AuthorizationManagerFactory` and a
bare `RoleHierarchy` bean (`@Autowired(required = false)` on each), so a plain `RoleHierarchy`
bean still works. The factory is where the prefix and the hierarchy now live together, and it
is the one that is not deprecated.
Verified in [`output/demo4.txt`](output/demo4.txt): with the hierarchy above, `root` holding
only `ROLE_ADMIN` passes `hasRole('GUEST')`.
[← 01 · how it runs](01-how-method-security-runs.md) · [chapter index](README.md) · [next: self-invocation →](03-self-invocation.md)

View File

@@ -0,0 +1,90 @@
[← 02 · SpEL reference](02-spel-reference.md) · [chapter index](README.md) · [next: non-proxyable methods →](04-non-proxyable-methods.md)
# 03 · Self-invocation
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo2SelfInvocation`
Output: [`output/demo2.txt`](output/demo2.txt)
Source: [`Demo2SelfInvocation.java`](../src/main/java/com/ankurm/methodsec/Demo2SelfInvocation.java)
## The shape
```java
@PreAuthorize("hasRole('ADMIN')")
public String adminReport() { return "TOP SECRET REVENUE NUMBERS"; }
public String userEntryPoint() {
return adminReport(); // no proxy involved
}
```
A caller invoking `adminReport()` from outside goes through the proxy and is denied. A caller
invoking `userEntryPoint()` reaches the target object, and `this.adminReport()` is a plain
virtual call on the target. The advisor never sees it.
From [`output/demo2.txt`](output/demo2.txt), as a user holding only `ROLE_USER`:
```
reports.adminReport() (via proxy) DENIED -> AuthorizationDeniedException
reports.userEntryPoint() (this.adminReport()) ALLOWED -> TOP SECRET REVENUE NUMBERS
```
## Why it survives review
The same run prints:
```
ReportService.adminReport() @PreAuthorize -> @PreAuthorize("hasRole('ADMIN')")
bean is an AOP proxy -> true
proxy class -> ...ReportService$$SpringCGLIB$$0
```
The annotation is present. The bean *is* proxied. Reflection agrees with the source. Every
individual check a reviewer would run passes; only the composition is wrong. Nothing logs at
any level, no metric moves, and the method returns the right answer to the wrong person.
## Three fixes, in order of preference
**Move the method to a different bean.** A call between two beans is an external call by
definition. This is the boring answer and it is usually the right one, because a method that
needs its own authorization rule is usually a different responsibility.
**Inject the bean into itself through an `ObjectProvider`.**
```java
private final ObjectProvider<ReportService> self;
public String viaSelfInjection() {
return this.self.getObject().adminReport();
}
```
A direct field of the bean's own type is a circular reference the container refuses in a
constructor; `ObjectProvider` resolves lazily at call time and hands back the proxy.
**`AopContext.currentProxy()`.**
```java
public String viaAopContext() {
return ((ReportService) AopContext.currentProxy()).adminReport();
}
```
This needs `@EnableAspectJAutoProxy(exposeProxy = true)` — without it the call throws
`IllegalStateException: Cannot find current proxy: Set 'exposeProxy' property on Advised to
'true' to make it available`. It also couples the method to the fact that it is proxied, which
is why it is last on this list.
All three are verified in the same run; all three produce `AuthorizationDeniedException`.
## The structural fix
`@EnableMethodSecurity(mode = AdviceMode.ASPECTJ)` weaves the advice into the bytecode
instead of wrapping the object, and self-invocation stops being a thing. It also requires
AspectJ weaving in the build, which is a large change to make for one class of bug. Consider it
if you are already weaving.
The cheaper structural answer is not to depend on method security alone: keep a catch-all
`authorizeHttpRequests` rule (`anyRequest().authenticated()`) so that an inner method that
escapes its own check is still behind a request-level one.
[← 02 · SpEL reference](02-spel-reference.md) · [chapter index](README.md) · [next: non-proxyable methods →](04-non-proxyable-methods.md)

View File

@@ -0,0 +1,85 @@
[← 03 · self-invocation](03-self-invocation.md) · [chapter index](README.md) · [next: filtering →](05-filtering.md)
# 04 · Methods the proxy cannot advise
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo3NonProxyable`
Output: [`output/demo3.txt`](output/demo3.txt)
Source: [`Demo3NonProxyable.java`](../src/main/java/com/ankurm/methodsec/Demo3NonProxyable.java)
A CGLIB proxy is a generated subclass. It intercepts a method by overriding it. Anything that
cannot be overridden cannot be advised.
## The results
Same class, same `@PreAuthorize("hasRole('ADMIN')")` on every method, same `ROLE_USER` caller:
| Declaration | Advised? | How you find out |
|---|---|---|
| `public` | yes | denied, as intended |
| `public final` | **no** | a `WARNING` from `CglibAopProxy` at startup |
| `static` | **no** | nothing at all |
| package-private | yes | denied — the generated subclass is in the same package |
| `private` | **no** | nothing at all; also unreachable except by self-invocation |
| `final class` | n/a | the context **fails to start** |
The package-private row is the surprise. Received wisdom is "only public methods are secured";
the generated subclass lands in the same package as the target, so it can and does override a
package-private method. Verified by reflection in the same run:
```
publicAdminOnly declared final=false overridden by proxy=true
finalAdminOnly declared final=true overridden by proxy=false
packagePrivateAdminOnly declared final=false overridden by proxy=true
```
## `final` is the loud one, `static` and `private` are not
Spring does warn about a public `final` method — `CglibAopProxy.doValidateClass` logs:
```
WARNING: Public final method [public final java.lang.String ...Vault.finalAdminOnly()]
cannot get proxied via CGLIB, consider removing the final marker or using interface-based
JDK proxies.
```
It is in [`output/demo3.txt`](output/demo3.txt), at the very top, at startup, mixed in with
everything else an application logs while booting. It is easy to miss and it is more than you
get for `static` and `private`, which produce nothing.
A `final` **class** is different again: CGLIB cannot subclass it at all, so the container
refuses to start with
`IllegalArgumentException: Cannot subclass final class ...SealedVault`. That is the friendliest
failure in this chapter. It is also the reason a Java `record` cannot carry method security on
its own methods — records are final. If you need a secured getter on a returned object (see
[chapter 06](06-denied-handling.md)), it has to be a non-final class.
## Interfaces and JDK proxies
`@EnableMethodSecurity(proxyTargetClass = false)` is the default, so a bean that implements an
interface gets a **JDK dynamic proxy**, which implements only the interfaces. A public
annotated method that is not on the interface is then not merely unadvised — it is not on the
proxy at all:
```
proxy is a JDK proxy -> true
proxied interfaces -> [interface ...LedgerOperations]
cast proxy to Ledger impl -> ClassCastException: class jdk.proxy2.$Proxy18 cannot be cast to ...Ledger
```
In a Spring Boot application this is usually moot: Boot sets `proxyTargetClass = true` globally
via `spring.aop.proxy-target-class`, which defaults to `true`. In a plain Spring context, or
with that property flipped, it is live. Either way, the `ClassCastException` is loud — the
dangerous version is the one where the interface method *is* annotated and the implementation
carries a second, different annotation; see [chapter 08](08-meta-annotations.md).
## What to actually do
- Do not put `@PreAuthorize` on anything `private` or `static`. Neither is reachable through a
proxy, and neither will tell you.
- Remove `final` from methods that carry security annotations, or make peace with the fact that
the annotation is documentation.
- If you use records or other final classes as return values, secure the method that returns
them, not the accessors on them.
- Grep for the combination. [Chapter 09](09-audit-checklist.md) has the patterns.
[← 03 · self-invocation](03-self-invocation.md) · [chapter index](README.md) · [next: filtering →](05-filtering.md)

View File

@@ -0,0 +1,120 @@
[← 04 · non-proxyable methods](04-non-proxyable-methods.md) · [chapter index](README.md) · [next: denial handling →](06-denied-handling.md)
# 05 · Filtering, `filterObject`, and the third silent failure
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo5FilteringTraps`
Output: [`output/demo5.txt`](output/demo5.txt)
Source: [`Demo5FilteringTraps.java`](../src/main/java/com/ankurm/methodsec/Demo5FilteringTraps.java)
`@PreFilter` and `@PostFilter` evaluate their expression once per element, with the element
bound to `filterObject`, and drop the ones that evaluate false.
## `@PreFilter` mutates the caller's collection
It does not hand your method a filtered copy. It clears the collection the caller passed and
adds the survivors back into it. From [`output/demo1.txt`](output/demo1.txt):
```
caller's list before the call : [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
method body saw : [Account[1,alice,150], Account[3,alice,350]]
caller's list after the call : [Account[1,alice,150], Account[3,alice,350]]
```
The caller's own list lost an element. If that list was a field, a cache, or a collection
shared with anything else, it lost the element there too.
## The silent failure: an immutable argument
```
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
List.of(..) (immutable) ALLOWED -> (void)
```
Bob's account reached the method body. No exception, no warning, no 403.
The mechanism, from
`DefaultMethodSecurityExpressionHandler.filterCollection` in Spring Security 7.1.1:
```java
try {
filterTarget.clear();
filterTarget.addAll(retain);
return filterTarget;
}
catch (UnsupportedOperationException readonly) {
this.logger.trace("Collection threw exception: ... Will return a new instance instead of mutating its state.");
return retain;
}
```
It computes the right answer, cannot write it back, and returns a fresh list instead. And
`PreFilterAuthorizationMethodInterceptor.invoke` is:
```java
Object filterTarget = findFilterTarget(attribute.getFilterTarget(), ctx, mi);
expressionHandler.filter(filterTarget, attribute.getExpression(), ctx);
return mi.proceed();
```
The return value of `filter(..)` is discarded. `@PreFilter` is *entirely* dependent on in-place
mutation working. When it does not, the filter is a no-op and the unfiltered elements go
straight into the method body.
The only trace is at TRACE level, and the demo turns it on so you can see what it looks like:
```
TRACE Retaining elements: [Account[1,alice,100], Account[3,alice,300]]
TRACE Collection threw exception: null. Will return a new instance instead of mutating its state.
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
```
`List.of(..)`, `List.copyOf(..)`, `Collections.unmodifiableList(..)`, `Arrays.asList(..)` (which
refuses `clear()`), a Guava `ImmutableList`, and the result of `Stream.toList()` are all
immutable. `Stream.toList()` is the one that catches people, because it looks like a neutral
terminal operation. `.collect(Collectors.toList())` returns a mutable `ArrayList`.
`@PostFilter` does not have this problem, because there the new list *is* the return value —
the same immutable input filters correctly. Verified in the same run.
## Container types
| Return / argument type | `@PreFilter` | `@PostFilter` | `filterObject` is |
|---|---|---|---|
| `Collection` (mutable) | works | works | the element |
| `Collection` (immutable) | **silent no-op** | works | the element |
| Array | `IllegalStateException` | works | the element |
| `Map` | works | works | the `Map.Entry` — use `filterObject.value.…` |
| `Stream` | works | works | the element |
| Anything else | `IllegalArgumentException` | `IllegalArgumentException` | — |
`Optional` is in the "anything else" row, and so is Spring Data's `Page` — a real `PageImpl`,
not a stand-in:
```
Optional<Account> IllegalArgumentException: ... but was Optional[Account[1,alice,100]]
Page<Account> (real Spring Data PageImpl) IllegalArgumentException: ... but was Page 1 of 1 containing ...Account instances
```
`PageImpl` implements `Slice``Streamable``Iterable`, but not `Collection`, so it falls
through every branch of `DefaultMethodSecurityExpressionHandler.filter`. `@PostFilter` on a
repository method returning `Page<T>` therefore throws at runtime, not at startup. And
filtering a page in memory would give you the wrong page size anyway — filter in the query.
## Two more sharp edges
**More than one argument needs `filterTarget`.** `@PreFilter` with two parameters and no
`filterTarget` throws `IllegalStateException: Unable to determine the method argument for
filtering. Specify the filter target.` — at invocation time, not at startup, so a rarely
exercised method can ship broken. Name the argument:
`@PreFilter(value = "…", filterTarget = "accounts")`.
**`@PostFilter` returns the same instance it filtered.** Verified:
```
returned == the list the method returned : true
```
If the method returned a cached or shared collection, `@PostFilter` has now deleted elements
from it for every future caller. Return a defensive copy from any method you filter.
[← 04 · non-proxyable methods](04-non-proxyable-methods.md) · [chapter index](README.md) · [next: denial handling →](06-denied-handling.md)

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)

View File

@@ -0,0 +1,121 @@
[← 06 · denial handling](06-denied-handling.md) · [chapter index](README.md) · [next: meta-annotations →](08-meta-annotations.md)
# 07 · Ordering, and `@PostAuthorize` vs `@Transactional`
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo6InterceptorOrder`
Output: [`output/demo6.txt`](output/demo6.txt)
Source: [`Demo6InterceptorOrder.java`](../src/main/java/com/ankurm/methodsec/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:
```java
@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.
```java
@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:
```java
@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](03-self-invocation.md) and
[chapter 04](04-non-proxyable-methods.md) 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](06-denied-handling.md) · [chapter index](README.md) · [next: meta-annotations →](08-meta-annotations.md)

View File

@@ -0,0 +1,99 @@
[← 07 · ordering and transactions](07-ordering-and-transactions.md) · [chapter index](README.md) · [next: the audit checklist →](09-audit-checklist.md)
# 08 · Meta-annotations, templates, class-level rules, ambiguity
Run: `java -cp target/classes:$(cat cp.txt) com.ankurm.methodsec.Demo8MetaAnnotations`
Output: [`output/demo8.txt`](output/demo8.txt)
Source: [`Demo8MetaAnnotations.java`](../src/main/java/com/ankurm/methodsec/Demo8MetaAnnotations.java)
## Naming a rule
```java
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin { }
```
`@IsAdmin` now works anywhere `@PreAuthorize("hasRole('ADMIN')")` would, with no configuration.
The value is not brevity — it is that the rule exists in one place, so changing it is an edit
rather than a grep.
## Templated meta-annotations, and a documentation correction
```java
@PreAuthorize("hasRole('{value}')")
public @interface HasRole { String value(); }
```
The reference documentation says you must publish an `AnnotationTemplateExpressionDefaults`
bean for `{value}` to be substituted. **In Spring Security 7.1.1 you do not.**
[`output/demo8.txt`](output/demo8.txt) shows `@HasRole("ADMIN")` discriminating correctly
between an admin and a non-admin in a context with no such bean.
The reason is in `PreAuthorizeExpressionAttributeRegistry`, which initialises its scanner as
`SecurityAnnotationScanners.requireUnique(PreAuthorize.class)` — and that overload constructs
`new AnnotationTemplateExpressionDefaults()` internally. The bean is autowired
`@Autowired(required = false)` and only replaces that default. Its single knob is
`setIgnoreUnknown(false)`, which turns an unrecognised placeholder into an error instead of
leaving it in the expression. That is worth publishing the bean for; making templates work at
all is not.
One caveat, read from the source rather than executed here:
`SecurityAnnotationScanners.requireUnique` memoises scanners in a static map keyed by
annotation type. Within a single JVM the first configuration for a given annotation type is the
one that sticks — relevant if you run several differently-configured contexts in one test JVM.
Multi-value templates need the quotes inside the attribute, which is as awkward as it looks:
```java
@PreAuthorize("hasAnyRole({roles})")
public @interface HasAnyRole { String[] roles(); }
@HasAnyRole(roles = { "'USER'", "'ADMIN'" })
```
## Class level
`@PreAuthorize` on the class applies to every method. A method-level `@PreAuthorize` **replaces**
it rather than adding to it — the nearest declaration wins:
```
inherited from the class (needs ADMIN) DENIED
method-level overrides it (needs USER) ALLOWED
```
Different annotation *types* are ANDed. A class-level `@PreAuthorize` and a method-level
`@PostAuthorize` both have to pass.
The trap is the override. Adding `@PreAuthorize("hasRole('USER')")` to one method of an
`@PreAuthorize("hasRole('ADMIN')")` class looks like a tightening and is a widening.
## Two interfaces, two rules
```java
public interface ReadsAsUser { @PreAuthorize("hasRole('USER')") String read(); }
public interface ReadsAsAdmin { @PreAuthorize("hasRole('ADMIN')") String read(); }
public class Ambiguous implements ReadsAsUser, ReadsAsAdmin { public String read() { ... } }
```
Spring Security refuses to pick one:
```
AnnotationConfigurationException: Please ensure there is one unique annotation of type
[interface ...PreAuthorize] attributed to public abstract java.lang.String ...ReadsAsUser.read().
Found 2 competing annotations: [@PreAuthorize("hasRole('USER')"), @PreAuthorize("hasRole('ADMIN')")]
```
**Correction to the common description:** this is *not* a startup failure. The context
refreshes, the bean is proxied, and the exception appears the first time the method is called.
Guidance that says "if a class inherits the same annotation from two interfaces, startup fails"
describes a friendlier framework than the one you are running. Verified in
[`output/demo8.txt`](output/demo8.txt).
The fix is to annotate the implementation method, which is the nearest declaration and wins
outright. The broader lesson is to put authorization rules in one layer and keep them there.
Repeating the same annotation twice on one method is also unsupported — combine with `and`/`or`
inside a single expression, or delegate to a bean.
[← 07 · ordering and transactions](07-ordering-and-transactions.md) · [chapter index](README.md) · [next: the audit checklist →](09-audit-checklist.md)

View File

@@ -0,0 +1,97 @@
[← 08 · meta-annotations](08-meta-annotations.md) · [chapter index](README.md)
# 09 · The audit checklist
What to run against an existing codebase, roughly in order of how often it finds something.
## Grep for it
```bash
# annotations on methods a proxy cannot advise
grep -rnE '@(Pre|Post)(Authorize|Filter)' --include='*.java' -A3 . \
| grep -E '(private|static|final) .*\('
# final classes carrying method security (context will refuse to start, but check anyway)
grep -rlE '@(Pre|Post)Authorize' --include='*.java' . | xargs grep -lE '^public final class'
# @Secured / JSR-250 in a codebase that never enabled them
grep -rlE '@(Secured|RolesAllowed|PermitAll|DenyAll)' --include='*.java' . >/dev/null \
&& grep -rn 'EnableMethodSecurity' --include='*.java' .
# @PreFilter reached with a list that may be immutable
grep -rn '@PreFilter' --include='*.java' -A5 . # then check every caller
# @PostAuthorize on a method that writes
grep -rn '@PostAuthorize' --include='*.java' -B3 . | grep -i 'transactional'
```
Self-invocation does not grep well. The signal is a public method with no annotation calling an
annotated method on the same class; an IDE "find usages" on each annotated method, filtered to
its own file, finds them faster than a regex.
## Check at runtime
Print the advisor chain for a bean you believe is secured. If the bean has zero advisors, none
of its annotations are doing anything:
```java
if (bean instanceof Advised advised) {
for (Advisor a : advised.getAdvisors()) {
System.out.println(((Ordered) a).getOrder() + " " + a);
}
}
```
Then confirm the build is passing `-parameters` — one reflection call answers it:
```java
SomeService.class.getMethod("byOwner", String.class).getParameters()[0].isNamePresent()
```
`false` means every `#parameterName` expression in the application is comparing against
nothing. See [chapter 02](02-spel-reference.md).
## Test for it
The thing worth asserting is the **negative**: that an unauthorised caller is refused. A test
that only checks the happy path passes identically whether or not the annotation is being
applied at all, which makes it worse than no test. `MethodSecurityTrapsTest` in this module is
14 such assertions —
[`src/test/java/com/ankurm/methodsec/MethodSecurityTrapsTest.java`](../src/test/java/com/ankurm/methodsec/MethodSecurityTrapsTest.java),
output in [`output/tests.txt`](output/tests.txt).
Use `@WithMockUser(roles = "USER")` and assert `AuthorizationDeniedException`. Call the method
through the injected bean, never through `new`.
## Edge-case index
Everything this module demonstrates, with the file that proves it:
| # | Behaviour | Where |
|---|---|---|
| 1 | `@Secured` / JSR-250 inert unless enabled | [01](01-how-method-security-runs.md), [`demo1.txt`](output/demo1.txt) |
| 2 | No `Authentication` gives `AuthenticationCredentialsNotFoundException`, not a denial | [01](01-how-method-security-runs.md), [`demo1.txt`](output/demo1.txt) |
| 3 | `hasAllRoles` / `hasAllAuthorities` exist | [02](02-spel-reference.md), [`demo4.txt`](output/demo4.txt) |
| 4 | `#root.args[0]` does not exist | [02](02-spel-reference.md), [`demo4.txt`](output/demo4.txt) |
| 5 | `#parameterName` needs `-parameters` | [02](02-spel-reference.md), [`demo9-*.txt`](output/) |
| 6 | `setRoleHierarchy` deprecated; `AuthorizationManagerFactory` is the 7.1 knob | [02](02-spel-reference.md), [`demo4.txt`](output/demo4.txt) |
| 7 | Self-invocation bypasses the check | [03](03-self-invocation.md), [`demo2.txt`](output/demo2.txt) |
| 8 | `final` / `static` / `private` methods are not advised | [04](04-non-proxyable-methods.md), [`demo3.txt`](output/demo3.txt) |
| 9 | Package-private methods **are** advised | [04](04-non-proxyable-methods.md), [`demo3.txt`](output/demo3.txt) |
| 10 | `final` class fails at startup | [04](04-non-proxyable-methods.md), [`demo3.txt`](output/demo3.txt) |
| 11 | JDK proxy hides non-interface methods entirely | [04](04-non-proxyable-methods.md), [`demo3.txt`](output/demo3.txt) |
| 12 | `@PreFilter` on an immutable collection is a silent no-op | [05](05-filtering.md), [`demo5.txt`](output/demo5.txt) |
| 13 | `@PreFilter` mutates the caller's own collection | [05](05-filtering.md), [`demo1.txt`](output/demo1.txt) |
| 14 | `@PreFilter` needs `filterTarget` past one argument; rejects arrays | [05](05-filtering.md), [`demo1.txt`](output/demo1.txt), [`demo5.txt`](output/demo5.txt) |
| 15 | `@PostFilter` returns the same instance it filtered | [05](05-filtering.md), [`demo5.txt`](output/demo5.txt) |
| 16 | `Optional` and `Page` are not filterable | [05](05-filtering.md), [`demo5.txt`](output/demo5.txt) |
| 17 | `AuthorizationDeniedException` carries an `AuthorizationResult` | [06](06-denied-handling.md), [`demo7.txt`](output/demo7.txt) |
| 18 | `@AuthorizeReturnObject` cannot secure a record | [06](06-denied-handling.md), [`demo7.txt`](output/demo7.txt) |
| 19 | `AuthorizationProxyFactory` package correction | [06](06-denied-handling.md), [`demo7.txt`](output/demo7.txt) |
| 20 | `@PostAuthorize` sees the already-filtered return value | [07](07-ordering-and-transactions.md), [`demo6.txt`](output/demo6.txt) |
| 21 | A denied `@PostAuthorize` does not roll back by default | [07](07-ordering-and-transactions.md), [`demo6.txt`](output/demo6.txt) |
| 22 | `{value}` templates work without the defaults bean | [08](08-meta-annotations.md), [`demo8.txt`](output/demo8.txt) |
| 23 | Method-level `@PreAuthorize` replaces the class-level one | [08](08-meta-annotations.md), [`demo8.txt`](output/demo8.txt) |
| 24 | Conflicting inherited annotations fail at call time, not startup | [08](08-meta-annotations.md), [`demo8.txt`](output/demo8.txt) |
[← 08 · meta-annotations](08-meta-annotations.md) · [chapter index](README.md)

View File

@@ -0,0 +1,19 @@
# Method security: the chapters
Companion notes for [Method Security in Spring Security 7](https://ankurm.com/spring-security-7-method-security-proxy-traps/)
on ankurm.com. Read in order, or jump to whichever failure you are currently staring at.
| # | Chapter | Answers |
|---|---|---|
| 01 | [How method security actually runs](01-how-method-security-runs.md) | What `@EnableMethodSecurity` registers, and what happens between the caller and the method body |
| 02 | [The SpEL reference](02-spel-reference.md) | Everything you can write inside the annotation, evaluated for real |
| 03 | [Self-invocation](03-self-invocation.md) | Silent failure #1, why it happens, and three fixes |
| 04 | [Methods the proxy cannot advise](04-non-proxyable-methods.md) | Silent failure #2: `final`, `static`, `private`, interfaces, final classes |
| 05 | [Filtering and `filterObject`](05-filtering.md) | Silent failure #3: `@PreFilter` on an immutable argument, and which container types work |
| 06 | [Denial: what is thrown, and how to change it](06-denied-handling.md) | `AuthorizationDeniedException`, `@HandleAuthorizationDenied`, `@AuthorizeReturnObject` |
| 07 | [Ordering, and `@PostAuthorize` vs `@Transactional`](07-ordering-and-transactions.md) | Why a denied `@PostAuthorize` does not roll anything back by default |
| 08 | [Meta-annotations and templates](08-meta-annotations.md) | Custom annotations, `{value}` templates, class-level rules, ambiguity |
| 09 | [The audit checklist](09-audit-checklist.md) | What to grep for in an existing codebase, plus the edge-case index |
Every claim in these chapters has a file under [`output/`](output/) behind it, regenerated by
`scripts/run-all.sh`.

View File

@@ -0,0 +1,50 @@
==============================================================================
Demo 1 -- the four pre/post annotations, @Secured and JSR-250, all switched on
==============================================================================
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
as alice (ROLE_USER)
--------------------
@PreAuthorize hasRole('ADMIN') DENIED -> AuthorizationDeniedException: Access Denied
@PreAuthorize #owner == authentication.name ALLOWED -> [Account[1,alice,100], Account[3,alice,300]]
@PreAuthorize #owner == authentication.name DENIED -> AuthorizationDeniedException: Access Denied
@PostAuthorize returnObject.owner == ...name ALLOWED -> Account[1,alice,100]
@PostAuthorize returnObject.owner == ...name DENIED -> AuthorizationDeniedException: Access Denied
@PostFilter filterObject.owner == ...name ALLOWED -> [Account[1,alice,100], Account[3,alice,300]]
@Secured("ROLE_ADMIN") DENIED -> AuthorizationDeniedException: Access Denied
@RolesAllowed("ADMIN") DENIED -> AuthorizationDeniedException: Access Denied
@PermitAll ALLOWED -> open payload
@DenyAll DENIED -> AuthorizationDeniedException: Access Denied
as root (ROLE_ADMIN, ROLE_USER)
-------------------------------
@PreAuthorize hasRole('ADMIN') ALLOWED -> the admin console
@Secured("ROLE_ADMIN") ALLOWED -> secured payload
@RolesAllowed("ADMIN") ALLOWED -> jsr250 payload
@PostFilter filterObject.owner == ...name ALLOWED -> []
with no Authentication at all (SecurityContextHolder cleared)
-------------------------------------------------------------
@PreAuthorize hasRole('ADMIN') DENIED -> AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext
@PermitAll ALLOWED -> open payload
@PreFilter -- filtering the ARGUMENT, as alice
----------------------------------------------
caller's list before the call : [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
method body saw : [Account[1,alice,150], Account[3,alice,350]]
caller's list after the call : [Account[1,alice,150], Account[3,alice,350]]
@PreFilter did not hand the method a copy. It removed bob's account from
the caller's own list, in place, before the method body ever ran. That is
why Demo 5's immutable List.of(..) blows up.
@PreFilter on a method with more than one argument
--------------------------------------------------
no filterTarget, 2 args DENIED -> IllegalStateException: Unable to determine the method argument for filtering. Specify the filter target.
method body saw : [Account[4,alice,60]]
filterTarget = "accounts" ALLOWED -> (void)
This one is loud, not silent -- but it only fires when the method is
actually called, so a rarely-exercised path can ship broken.

View File

@@ -0,0 +1,27 @@
==============================================================================
Demo 2 -- self-invocation: the annotation is there, the check is not
==============================================================================
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
alice has ROLE_USER only. adminReport() requires ROLE_ADMIN.
------------------------------------------------------------
reports.adminReport() (via proxy) DENIED -> AuthorizationDeniedException: Access Denied
reports.userEntryPoint() (this.adminReport()) ALLOWED -> TOP SECRET REVENUE NUMBERS
Is the annotation actually there? (reflection on the target class)
------------------------------------------------------------------
ReportService.adminReport() @PreAuthorize -> @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
bean is an AOP proxy -> true
proxy class -> com.ankurm.methodsec.Demo2SelfInvocation$ReportService$$SpringCGLIB$$0
target class -> com.ankurm.methodsec.Demo2SelfInvocation$ReportService
The annotation is present, the bean IS proxied, and the call was still
not checked. The proxy only sees calls that arrive from outside.
Three ways to make the inner call go through the proxy
------------------------------------------------------
self-injection (ObjectProvider) DENIED -> AuthorizationDeniedException: Access Denied
AopContext.currentProxy() DENIED -> AuthorizationDeniedException: Access Denied
call a different bean (collaborator) DENIED -> AuthorizationDeniedException: Access Denied

View File

@@ -0,0 +1,37 @@
==============================================================================
Demo 3 -- @PreAuthorize on methods the proxy cannot override
==============================================================================
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
alice has ROLE_USER. Every method below says hasRole('ADMIN').
--------------------------------------------------------------
public (overridable) DENIED -> AuthorizationDeniedException: Access Denied
public final (NOT overridable) ALLOWED -> final payload
static (NOT overridable) ALLOWED -> static payload
package-private (overridable, same package) DENIED -> AuthorizationDeniedException: Access Denied
private, reached via a public wrapper ALLOWED -> private payload
What the proxy actually overrode
--------------------------------
publicAdminOnly declared final=false overridden by proxy=true
finalAdminOnly declared final=true overridden by proxy=false
packagePrivateAdminOnly declared final=false overridden by proxy=true
proxy class -> com.ankurm.methodsec.Demo3NonProxyable$Vault$$SpringCGLIB$$0
A JDK dynamic proxy only advises methods that are ON the interface
------------------------------------------------------------------
proxy is a JDK proxy -> true
proxied interfaces -> [interface com.ankurm.methodsec.Demo3NonProxyable$LedgerOperations]
onTheInterface() (advised) DENIED -> AuthorizationDeniedException: Access Denied
notOnTheInterface() is public and annotated, but the JDK proxy does not
implement it at all -- a caller cannot even reach it without casting to
the implementation class, and that cast throws ClassCastException.
cast proxy to Ledger impl class DENIED -> ClassCastException: class jdk.proxy2.$Proxy18 cannot be cast to class com.ankurm.methodsec.Demo3NonProxyable$Ledger (jdk.proxy2.$Proxy18 is in module jdk.proxy2 of loader 'app'; com.ankurm.methodsec.Demo3NonProxyable$Ledger is in unnamed module of loader 'app')
A final CLASS is the loud one
-----------------------------
startup FAILED -> BeanCreationException
root cause -> java.lang.IllegalArgumentException
message -> Cannot subclass final class com.ankurm.methodsec.Demo3NonProxyable$SealedVault

View File

@@ -0,0 +1,69 @@
==============================================================================
Demo 4 -- what you can actually write inside @PreAuthorize / @PostAuthorize
==============================================================================
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
alice: ROLE_USER, ROLE_AUDITOR, plus the authority 'report:read'
----------------------------------------------------------------
permitAll ALLOWED -> ok
denyAll DENIED -> AuthorizationDeniedException: Access Denied
isAuthenticated() ALLOWED -> ok
isAnonymous() DENIED -> AuthorizationDeniedException: Access Denied
isFullyAuthenticated() ALLOWED -> ok
isRememberMe() DENIED -> AuthorizationDeniedException: Access Denied
hasRole('ADMIN') DENIED -> AuthorizationDeniedException: Access Denied
hasAnyRole('ADMIN','AUDITOR') ALLOWED -> ok
hasAllRoles('USER','AUDITOR') ALLOWED -> ok
hasAuthority('report:read') ALLOWED -> ok
hasAnyAuthority('report:read','x') ALLOWED -> ok
hasAllAuthorities('report:read','x') DENIED -> AuthorizationDeniedException: Access Denied
authentication.name == 'alice' ALLOWED -> ok
principal == 'alice' ALLOWED -> ok
root: ROLE_ADMIN only (RoleHierarchy says ADMIN > USER > GUEST)
---------------------------------------------------------------
permitAll ALLOWED -> ok
denyAll DENIED -> AuthorizationDeniedException: Access Denied
isAuthenticated() ALLOWED -> ok
isAnonymous() DENIED -> AuthorizationDeniedException: Access Denied
isFullyAuthenticated() ALLOWED -> ok
isRememberMe() DENIED -> AuthorizationDeniedException: Access Denied
hasRole('ADMIN') ALLOWED -> ok
hasAnyRole('ADMIN','AUDITOR') ALLOWED -> ok
hasAllRoles('USER','AUDITOR') DENIED -> AuthorizationDeniedException: Access Denied
hasAuthority('report:read') DENIED -> AuthorizationDeniedException: Access Denied
hasAnyAuthority('report:read','x') DENIED -> AuthorizationDeniedException: Access Denied
hasAllAuthorities('report:read','x') DENIED -> AuthorizationDeniedException: Access Denied
authentication.name == 'alice' DENIED -> AuthorizationDeniedException: Access Denied
principal == 'alice' DENIED -> AuthorizationDeniedException: Access Denied
Role prefix and hierarchy
-------------------------
hasRole('USER') -> ROLE_USER ALLOWED -> ok
hasAuthority('USER') -> literal 'USER' DENIED -> AuthorizationDeniedException: Access Denied
hasAuthority('ROLE_USER') ALLOWED -> ok
root hasRole('GUEST') via RoleHierarchy ALLOWED -> ok
Method arguments, the return value, and bean references
-------------------------------------------------------
#owner == authentication.name ("alice") ALLOWED -> ok
#owner == authentication.name ("bob") DENIED -> AuthorizationDeniedException: Access Denied
@P("o") alias, #o == ...name ("alice") ALLOWED -> ok
#root.this (the target object) ALLOWED -> ok
#root.args[0] -- no such property DENIED -> IllegalArgumentException: Failed to evaluate expression '#root.args[0] == authentication.name' [cause: SpelEvaluationException: EL1008E: Property or field 'args' cannot be found on object of type 'org.springframework.security.access.expression.method.MethodSecurityExpressionRoot' - maybe not public or not valid?]
@policy.canRead(authentication, #id) id=1 ALLOWED -> ok
@policy.canRead(authentication, #id) id=9 DENIED -> AuthorizationDeniedException: Access Denied
hasPermission(#id, 'account', 'read') id=1 ALLOWED -> ok
hasPermission(#id, 'account', 'read') id=9 DENIED -> AuthorizationDeniedException: Access Denied
T(java.time.LocalDate) type reference ALLOWED -> ok
@PostAuthorize returnObject.owner == ...name ALLOWED -> Account[1,alice,100]
@PostAuthorize returnObject.owner == ...name DENIED -> AuthorizationDeniedException: Access Denied
The literal constants on SecurityExpressionRoot
-----------------------------------------------
permitAll / denyAll exist as BOTH a boolean field and a no-arg method,
and read/write/create/delete/admin are String constants meant for
hasPermission(..) -- e.g. hasPermission(#id, 'account', read).
hasPermission(#id, 'account', read) id=1 ALLOWED -> ok

View File

@@ -0,0 +1,58 @@
==============================================================================
Demo 5 -- filterObject: mutability, container types, and the silent no-op
==============================================================================
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
@PreFilter needs a MUTABLE argument -- and does not tell you when it is not
---------------------------------------------------------------------------
method body saw: [Account[1,alice,100], Account[3,alice,300]]
new ArrayList<>(..) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
List.of(..) (immutable) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
List.copyOf(..) (immutable) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
Arrays.asList(..) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
Collections.unmodifiableList(..) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
stream().toList() (unmodifiable since 16) ALLOWED -> (void)
method body saw: [Account[1,alice,100], Account[3,alice,300]]
stream().collect(toList()) (ArrayList) ALLOWED -> (void)
Account[] (arrays rejected outright) DENIED -> IllegalStateException: Pre-filtering on array types is not supported. Using a Collection will solve this problem.
Read the second and third lines again: bob's account reached the method
body. @PreFilter filters by CLEARING the caller's collection and adding
the survivors back. On an immutable list that throws, and
DefaultMethodSecurityExpressionHandler.filterCollection catches the
UnsupportedOperationException and returns a fresh list instead -- which
PreFilterAuthorizationMethodInterceptor.invoke then discards, because it
ignores filter()'s return value entirely. No exception, no WARN, no 403.
The only trace it leaves (same call, logger at TRACE)
-----------------------------------------------------
method body saw: [Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
List.of(..) with TRACE on ALLOWED -> (void)
What @PostFilter accepts as a return type
-----------------------------------------
List<Account> ALLOWED -> [Account[1,alice,100], Account[3,alice,300]]
Account[] ALLOWED -> [Account[1,alice,100], Account[3,alice,300]]
Stream<Account> (collected here) ALLOWED -> [alice, alice]
Map<String, Account> ALLOWED -> {acct-1=Account[1,alice,100], acct-3=Account[3,alice,300]}
Optional<Account> (alice's) DENIED -> IllegalArgumentException: Filter target must be a collection, array, map or stream type, but was Optional[Account[1,alice,100]]
Optional<Account> (bob's) DENIED -> IllegalArgumentException: Filter target must be a collection, array, map or stream type, but was Optional[Account[2,bob,200]]
List.of(..) (immutable return) ALLOWED -> [Account[1,alice,100], Account[3,alice,300]]
Ledger (a type Spring Security does not know) DENIED -> IllegalArgumentException: Filter target must be a collection, array, map or stream type, but was Ledger[Account[1,alice,100], Account[2,bob,200], Account[3,alice,300]]
Page<Account> (real Spring Data PageImpl) DENIED -> IllegalArgumentException: Filter target must be a collection, array, map or stream type, but was Page 1 of 1 containing com.ankurm.methodsec.Account instances
Identity: does @PostFilter hand back the same object?
-----------------------------------------------------
returned == the list the method returned : true
the method's own list, after filtering : [Account[1,alice,100], Account[3,alice,300]]
@PostFilter mutates the returned collection in place and hands the same
reference back. If that collection is a cached or shared instance, you
have just deleted rows from it for every future caller.

View File

@@ -0,0 +1,58 @@
==============================================================================
Demo 6 -- interceptor order, and @PostAuthorize vs @Transactional
==============================================================================
AuthorizationInterceptorsOrder, read from the enum itself
---------------------------------------------------------
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
Lower order = higher precedence = further OUT in the chain. Spring's own
@Transactional advisor defaults to Ordered.LOWEST_PRECEDENCE (2147483647),
which is larger than every number above -- so security wraps transactions,
not the other way round.
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
The advisor chain on a bean carrying all four annotations
---------------------------------------------------------
advisors applied to the proxy: 4
ORDER ADVISOR BEAN (as registered by @EnableMethodSecurity)
100 preFilterAuthorizationMethodInterceptor
200 preAuthorizeAuthorizationMethodInterceptor
450 authorizeReturnObjectMethodInterceptor
500 postAuthorizeAuthorizationMethodInterceptor
600 postFilterAuthorizationMethodInterceptor
(authorizeReturnObject sits at SECURE_RESULT = 450 and is registered
whether or not anything in the app uses @AuthorizeReturnObject.)
For BEFORE advice, a lower order runs earlier: @PreFilter (100) really
does run before @PreAuthorize (200). For AFTER advice the same numbers
mean the opposite. @PostAuthorize (500) sits FURTHER OUT than
@PostFilter (600), so on the way back out @PostFilter finishes first
and @PostAuthorize evaluates returnObject on the ALREADY-FILTERED list.
Both methods below return the same 3 elements and filter one away:
@PostAuthorize returnObject.size() == 3 DENIED -> AuthorizationDeniedException: Access Denied
@PostAuthorize returnObject.size() == 2 ALLOWED -> [a, b]
Default order: @PostAuthorize denies AFTER the transaction commits
------------------------------------------------------------------
rows before : 0
recordAndReturn("bob") @PostAuthorize DENIED -> AuthorizationDeniedException: Access Denied
rows after the denial : 1
@EnableTransactionManagement(order = FIRST): the write rolls back
-----------------------------------------------------------------
rows before : 0
recordAndReturn("bob") @PostAuthorize DENIED -> AuthorizationDeniedException: Access Denied
rows after the denial : 0

View File

@@ -0,0 +1,34 @@
==============================================================================
Demo 7 -- @HandleAuthorizationDenied and @AuthorizeReturnObject
==============================================================================
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
The exception type you actually catch
-------------------------------------
thrown -> org.springframework.security.authorization.AuthorizationDeniedException
is AccessDeniedException -> true
is AuthorizationDeniedException -> true
carries an AuthorizationResult -> ExpressionAuthorizationDecision granted=false
Handlers written against AccessDeniedException still work -- but the
concrete type carries the AuthorizationResult that explains the denial.
@HandleAuthorizationDenied: return something instead of throwing
----------------------------------------------------------------
maskedBalance() (alice, no ROLE_FINANCE) ALLOWED -> ***masked***
maskedList() (alice, no ROLE_FINANCE) ALLOWED -> []
maskedBalance() (cfo, has ROLE_FINANCE) ALLOWED -> 1,204,993.22
@AuthorizeReturnObject: the check moves onto the returned object
----------------------------------------------------------------
returned instance -> com.ankurm.methodsec.Demo7DeniedHandling$Customer$$SpringCGLIB$$0
customer.getName() (no authority needed) ALLOWED -> alice
customer.getEmail() (needs 'pii:read') DENIED -> AuthorizationDeniedException: Access Denied
customer.getEmail() (has 'pii:read') ALLOWED -> alice@example.com
Same thing without the annotation, via AuthorizationProxyFactory
----------------------------------------------------------------
raw.getEmail() (unproxied object) ALLOWED -> alice@example.com
wrapped.getEmail() (proxied object) DENIED -> AuthorizationDeniedException: Access Denied

View File

@@ -0,0 +1,47 @@
==============================================================================
Demo 8 -- meta-annotations, templates, class-level rules, ambiguity
==============================================================================
A plain meta-annotation needs no extra configuration
----------------------------------------------------
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
@IsAdmin (alice, ROLE_USER) DENIED -> AuthorizationDeniedException: Access Denied
@IsAdmin (root, ROLE_ADMIN) ALLOWED -> ok
A TEMPLATED meta-annotation, with NO AnnotationTemplateExpressionDefaults bean
------------------------------------------------------------------------------
@HasRole("ADMIN") as root (ROLE_ADMIN) ALLOWED -> ok
@HasRole("ADMIN") as alice (ROLE_USER) DENIED -> AuthorizationDeniedException: Access Denied
'{value}' was substituted anyway. The reference documentation says you
must publish an AnnotationTemplateExpressionDefaults bean for templated
meta-annotations to work; in 7.1.1 you do not.
PreAuthorizeExpressionAttributeRegistry initialises its scanner with
SecurityAnnotationScanners.requireUnique(PreAuthorize.class), and that
overload constructs a default AnnotationTemplateExpressionDefaults for
you. Publishing the bean only changes ignoreUnknown.
The same annotation WITH the AnnotationTemplateExpressionDefaults bean
----------------------------------------------------------------------
@HasRole("ADMIN") as root (ROLE_ADMIN) ALLOWED -> ok
@HasRole("ADMIN") as alice (ROLE_USER) DENIED -> AuthorizationDeniedException: Access Denied
@HasRole("USER") as alice (ROLE_USER) ALLOWED -> ok
Class-level rules, and what a method-level one does to them
-----------------------------------------------------------
inherited from the class (needs ADMIN) DENIED -> AuthorizationDeniedException: Access Denied
method-level overrides it (needs USER) ALLOWED -> ok
class @PreAuthorize AND method @PostAuthorize DENIED -> AuthorizationDeniedException: Access Denied
Two interfaces, two different @PreAuthorize on the same method
--------------------------------------------------------------
context started fine.
bean type -> jdk.proxy2.$Proxy21
read() -- inherits two conflicting rules DENIED -> AnnotationConfigurationException: Please ensure there is one unique annotation of type [interface org.springframework.security.access.prepost.PreAuthorize] attributed to public abstract java.lang.String com.ankurm.methodsec.Demo8MetaAnnotations$ReadsAsUser.read(). Found 2 competing annotations: [@org.springframework.security.access.prepost.PreAuthorize("hasRole('USER')"), @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")]
It is not a startup failure: the context refreshes, the bean is
proxied, and the conflict only surfaces when the method is called.
The fix is to put @PreAuthorize on the implementation method, which
is the nearest declaration and therefore wins outright.

View File

@@ -0,0 +1,24 @@
==============================================================================
Demo 9 -- #parameterName and the -parameters compiler flag
==============================================================================
compiled with -parameters : true
byParameterName param[0] : owner
byParameterAlias param[0] : owner (annotated @P("o"))
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
alice calling with her own name
-------------------------------
#owner == authentication.name ALLOWED -> ok
#o == authentication.name (@P("o")) ALLOWED -> ok
alice calling with somebody else's name
---------------------------------------
#owner == authentication.name DENIED -> AuthorizationDeniedException: Access Denied
#o == authentication.name (@P("o")) DENIED -> AuthorizationDeniedException: Access Denied
Without -parameters the first expression denies BOTH calls -- it fails
closed, which is the good direction, but it fails silently in the sense
that nothing tells you the rule is not the rule you wrote. @P("o") does
not depend on the flag, because the name is in the class file either way.

View File

@@ -0,0 +1,24 @@
==============================================================================
Demo 9 -- #parameterName and the -parameters compiler flag
==============================================================================
compiled with -parameters : false
byParameterName param[0] : arg0
byParameterAlias param[0] : arg0 (annotated @P("o"))
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
alice calling with her own name
-------------------------------
#owner == authentication.name DENIED -> AuthorizationDeniedException: Access Denied
#o == authentication.name (@P("o")) ALLOWED -> ok
alice calling with somebody else's name
---------------------------------------
#owner == authentication.name DENIED -> AuthorizationDeniedException: Access Denied
#o == authentication.name (@P("o")) DENIED -> AuthorizationDeniedException: Access Denied
Without -parameters the first expression denies BOTH calls -- it fails
closed, which is the good direction, but it fails silently in the sense
that nothing tells you the rule is not the rule you wrote. @P("o") does
not depend on the flag, because the name is in the class file either way.

View File

@@ -0,0 +1,6 @@
mvn test -- MethodSecurityTrapsTest (14 tests pinning every claim the demos print)
-------------------------------------------------------------------------------
Test set: com.ankurm.methodsec.MethodSecurityTrapsTest
-------------------------------------------------------------------------------
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.076 s -- in com.ankurm.methodsec.MethodSecurityTrapsTest