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:
76
method-security/docs/01-how-method-security-runs.md
Normal file
76
method-security/docs/01-how-method-security-runs.md
Normal 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)
|
||||
117
method-security/docs/02-spel-reference.md
Normal file
117
method-security/docs/02-spel-reference.md
Normal 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)
|
||||
90
method-security/docs/03-self-invocation.md
Normal file
90
method-security/docs/03-self-invocation.md
Normal 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)
|
||||
85
method-security/docs/04-non-proxyable-methods.md
Normal file
85
method-security/docs/04-non-proxyable-methods.md
Normal 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)
|
||||
120
method-security/docs/05-filtering.md
Normal file
120
method-security/docs/05-filtering.md
Normal 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)
|
||||
104
method-security/docs/06-denied-handling.md
Normal file
104
method-security/docs/06-denied-handling.md
Normal 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)
|
||||
121
method-security/docs/07-ordering-and-transactions.md
Normal file
121
method-security/docs/07-ordering-and-transactions.md
Normal 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)
|
||||
99
method-security/docs/08-meta-annotations.md
Normal file
99
method-security/docs/08-meta-annotations.md
Normal 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)
|
||||
97
method-security/docs/09-audit-checklist.md
Normal file
97
method-security/docs/09-audit-checklist.md
Normal 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)
|
||||
19
method-security/docs/README.md
Normal file
19
method-security/docs/README.md
Normal 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`.
|
||||
50
method-security/docs/output/demo1.txt
Normal file
50
method-security/docs/output/demo1.txt
Normal 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.
|
||||
27
method-security/docs/output/demo2.txt
Normal file
27
method-security/docs/output/demo2.txt
Normal 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
|
||||
37
method-security/docs/output/demo3.txt
Normal file
37
method-security/docs/output/demo3.txt
Normal 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
|
||||
69
method-security/docs/output/demo4.txt
Normal file
69
method-security/docs/output/demo4.txt
Normal 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
|
||||
58
method-security/docs/output/demo5.txt
Normal file
58
method-security/docs/output/demo5.txt
Normal 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.
|
||||
58
method-security/docs/output/demo6.txt
Normal file
58
method-security/docs/output/demo6.txt
Normal 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
|
||||
34
method-security/docs/output/demo7.txt
Normal file
34
method-security/docs/output/demo7.txt
Normal 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
|
||||
47
method-security/docs/output/demo8.txt
Normal file
47
method-security/docs/output/demo8.txt
Normal 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.
|
||||
24
method-security/docs/output/demo9-with-parameters.txt
Normal file
24
method-security/docs/output/demo9-with-parameters.txt
Normal 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.
|
||||
24
method-security/docs/output/demo9-without-parameters.txt
Normal file
24
method-security/docs/output/demo9-without-parameters.txt
Normal 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.
|
||||
6
method-security/docs/output/tests.txt
Normal file
6
method-security/docs/output/tests.txt
Normal 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
|
||||
Reference in New Issue
Block a user