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:
90
context-propagation/docs/05-reactive-context.md
Normal file
90
context-propagation/docs/05-reactive-context.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 5. ReactiveSecurityContextHolder and Reactor Context
|
||||
|
||||
[← Prev: Executor/ExecutorService wrapping](04-executor-wrapping.md) | [Next: Scheduled tasks →](06-scheduled-tasks.md)
|
||||
|
||||
Every other demo in this repository asks "does the *thread* on the other side of a hand-off
|
||||
see the `SecurityContext`?" `Demo5ReactiveContext.java` asks a different question, because
|
||||
WebFlux doesn't have a thread on the other side of anything in the sense the rest of this
|
||||
repo cares about -- a reactive chain hops between scheduler threads as operators execute, and
|
||||
none of those threads is dedicated to one request. Full output in
|
||||
[`docs/output/demo5.txt`](output/demo5.txt).
|
||||
|
||||
## Why ThreadLocal genuinely cannot work here
|
||||
|
||||
Scenarios A and B make the failure concrete rather than asserted. A sets
|
||||
`SecurityContextHolder` (a `ThreadLocal`) on the calling thread and reads it back
|
||||
immediately -- works, because nothing has moved threads yet. B does the identical setup, but
|
||||
inserts a single `.publishOn(Schedulers.boundedElastic())` between the write and the read --
|
||||
exactly what a real WebFlux event loop does routinely between operators -- and the context is
|
||||
gone:
|
||||
|
||||
```
|
||||
A) SecurityContextHolder (ThreadLocal), no scheduler hop: authenticated as alice
|
||||
B) SecurityContextHolder (ThreadLocal), AFTER publishOn to a different thread: NO AUTHENTICATION (lost)
|
||||
```
|
||||
|
||||
This is the actual mechanism behind the post's line "`ThreadLocal` doesn't work because
|
||||
operations jump between threads" -- not a general reactive-programming caveat, a specific,
|
||||
reproducible failure with a specific operator.
|
||||
|
||||
## ReactiveSecurityContextHolder: the fix, and why it survives the same hop
|
||||
|
||||
`ReactiveSecurityContextHolder.getContext()` doesn't read a `ThreadLocal` at all -- it reads
|
||||
Project Reactor's own `Context`, which is attached to the *subscription*, not a thread, and
|
||||
which Reactor propagates through every operator in the chain regardless of which
|
||||
`Scheduler` runs which step. `ReactiveSecurityContextHolder.withAuthentication(auth)` produces
|
||||
a `Context` you attach with `.contextWrite(...)`. Scenarios C and D repeat A and B with this
|
||||
mechanism instead, and D survives the identical `publishOn` hop that killed B:
|
||||
|
||||
```
|
||||
C) ReactiveSecurityContextHolder + contextWrite, no scheduler hop: Hello, carol
|
||||
D) ReactiveSecurityContextHolder + contextWrite, AFTER publishOn to a different thread: Hello, dave
|
||||
```
|
||||
|
||||
This is the post's `getProfile()` example, reproduced verbatim as `Demo5ReactiveContext.getProfile()`:
|
||||
|
||||
```java
|
||||
static Mono<String> getProfile() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(securityContext -> "Hello, " + securityContext.getAuthentication().getName())
|
||||
.defaultIfEmpty("Anonymous");
|
||||
}
|
||||
```
|
||||
|
||||
## Edge case: `map()` cannot emit `null`
|
||||
|
||||
Writing this demo's JUnit counterpart in [Chapter 8](08-testing-contract.md) hit a real
|
||||
`NullPointerException` on the first attempt: `Mono.map(...)` throws if the mapper function
|
||||
returns `null` (Reactor treats a `null` signal as a programming error, not an empty result --
|
||||
that's what `Mono.empty()`/`defaultIfEmpty()` are for). A lambda that reads
|
||||
`SecurityContextHolder.getContext().getAuthentication()` and returns it directly breaks the
|
||||
moment the authentication is absent. The fix used throughout this demo and its test is to map
|
||||
to a descriptive `String` ("NO AUTHENTICATION (lost)") instead of passing a possibly-null
|
||||
domain object through a reactive operator. This is a real trap for exactly the kind of
|
||||
diagnostic code you'd add while debugging a context-propagation bug in a reactive pipeline.
|
||||
|
||||
## Edge case: no context ever written
|
||||
|
||||
Scenario E calls `getProfile()` with no `.contextWrite(...)` anywhere upstream at all --
|
||||
`ReactiveSecurityContextHolder.getContext()` completes **empty**, not with an error, so
|
||||
`.defaultIfEmpty("Anonymous")` fires cleanly:
|
||||
|
||||
```
|
||||
E) getProfile() with no contextWrite() upstream at all: Anonymous
|
||||
```
|
||||
|
||||
This matters for the post's `/profile` endpoint: an anonymous request to a permitted path
|
||||
never throws inside `getProfile()`, it degrades to "Anonymous" -- the `defaultIfEmpty` isn't
|
||||
defensive boilerplate, it's covering a real, reachable case.
|
||||
|
||||
## What this means for `@AuthenticationPrincipal Mono<UserDetails>`
|
||||
|
||||
The post's second example, `getUser(@AuthenticationPrincipal Mono<UserDetails> user)`, is the
|
||||
same mechanism at one more remove: Spring Security resolves that `Mono` parameter by reading
|
||||
`ReactiveSecurityContextHolder` internally before your method runs, so it inherits everything
|
||||
in this chapter for free. If a `Mono<UserDetails>` argument comes back empty in a WebFlux
|
||||
controller for a request you expected to be authenticated, the two things worth checking first
|
||||
are exactly A and B above: is a `ThreadLocal`-based mechanism (yours or a library's) trying to
|
||||
read the context after a scheduler hop, and is `.contextWrite(...)` actually upstream of the
|
||||
read in the chain that populates it (`SecurityWebFilterChain` normally handles this for you,
|
||||
but a hand-rolled `WebFilter` that reorders operators can break it).
|
||||
Reference in New Issue
Block a user