Add virtual-thread, structured-concurrency demos for the Spring Security context propagation guide
Three verified programs (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency versions) backing the ankurm.com post: InheritableThreadLocal across thread models, @Async on a virtual-thread SimpleAsyncTaskExecutor (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator), and StructuredTaskScope.fork() propagation. Captured console output and docs chapters included.
This commit is contained in:
39
docs/01-inheritable-threadlocal.md
Normal file
39
docs/01-inheritable-threadlocal.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 1. Why InheritableThreadLocal behaves differently with virtual threads
|
||||
|
||||
[Next: Async + virtual threads →](02-async-virtual-threads.md)
|
||||
|
||||
`Demo1PlainThreadLocal.java` has no Spring in it at all. It exists to settle one question
|
||||
before Spring Security enters the picture: does `InheritableThreadLocal` actually behave
|
||||
differently once the thread on the other end is virtual?
|
||||
|
||||
## The three cases
|
||||
|
||||
Every `Thread` copies the creating thread's `InheritableThreadLocal` values **once, at
|
||||
construction time**. That single sentence explains everything Spring Security's concurrency
|
||||
support has ever had to work around:
|
||||
|
||||
- A **fresh platform `Thread`** picks up whatever was set on the thread that created it. Fine.
|
||||
- A **pooled platform thread** was constructed once, long ago, by the pool's internal thread
|
||||
factory. Every task submitted to it later runs on that same physical thread, so it keeps
|
||||
whatever `InheritableThreadLocal` value existed *when the pool created the worker*, not
|
||||
what the submitting thread had at submission time. `docs/output/demo1.txt` shows this
|
||||
directly: task 2 on a reused pool worker still reports `request-B`, not `request-C`, even
|
||||
though the caller updated the value in between.
|
||||
- A **virtual thread** is, in this respect, identical to the fresh-platform-thread case.
|
||||
`Executors.newVirtualThreadPerTaskExecutor()` and `Thread.ofVirtual().start(...)` both
|
||||
construct a brand new `Thread` object per task -- virtual threads are never pooled or
|
||||
reused the way platform worker threads are. So the "stale value from a reused thread"
|
||||
failure mode that made `SecurityContextHolder.MODE_INHERITABLETHREADLOCAL` dangerous with
|
||||
`ThreadPoolTaskExecutor` simply does not exist for virtual threads.
|
||||
|
||||
## Why this matters for the rest of the repo
|
||||
|
||||
Spring Security's docs (and the original version of the blog post this repo supports) warn
|
||||
against `MODE_INHERITABLETHREADLOCAL` because of the pooled-thread case above. That warning
|
||||
is correct for `ThreadPoolTaskExecutor`. It stops being the relevant risk once
|
||||
`spring.threads.virtual.enabled=true` swaps the executor for a `SimpleAsyncTaskExecutor`
|
||||
backed by virtual threads -- there is no pool left to go stale. [Chapter 2](02-async-virtual-threads.md)
|
||||
verifies that directly against `SecurityContextHolder`.
|
||||
|
||||
Run it yourself: `scripts/run-all.sh`, or just `demo1` from the output already captured in
|
||||
[`docs/output/demo1.txt`](output/demo1.txt).
|
||||
67
docs/02-async-virtual-threads.md
Normal file
67
docs/02-async-virtual-threads.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 2. @Async, DelegatingSecurityContextExecutor, and virtual threads on Boot 4.1
|
||||
|
||||
[← Prev: InheritableThreadLocal](01-inheritable-threadlocal.md) | [Next: Structured concurrency →](03-structured-concurrency.md)
|
||||
|
||||
`Demo2AsyncVirtualThreads.java` reproduces the exact executor bean Spring Boot 4.1 wires up
|
||||
when you set `spring.threads.virtual.enabled=true`: a `SimpleAsyncTaskExecutor` with
|
||||
`setVirtualThreads(true)`. That bean backs `@Async`, MVC async request handling, and WebFlux's
|
||||
blocking-execution support. It is not a `ThreadPoolTaskExecutor` and never has a fixed pool of
|
||||
workers to reuse -- see [Chapter 1](01-inheritable-threadlocal.md) for why that matters.
|
||||
|
||||
Four scenarios, same question each time: does the async task see the `Authentication` that was
|
||||
active on the calling thread? Full output in [`docs/output/demo2.txt`](output/demo2.txt).
|
||||
|
||||
## A) Default mode, unwrapped executor -- loses it
|
||||
|
||||
`SecurityContextHolder`'s default strategy, `MODE_THREADLOCAL`, does not travel to any new
|
||||
thread, virtual or not. This is the exact symptom reported against Spring Security as
|
||||
[gh-15040](https://github.com/spring-projects/spring-security/issues/15040): swap in a raw
|
||||
virtual-thread executor and `@Async` methods start throwing `AccessDeniedException` because
|
||||
`SecurityContextHolder.getContext().getAuthentication()` is `null`.
|
||||
|
||||
## B) MODE_INHERITABLETHREADLOCAL, unwrapped executor -- works
|
||||
|
||||
This is the finding from Chapter 1 applied to Spring Security directly. Because the virtual
|
||||
thread the executor spins up is fresh every time, `MODE_INHERITABLETHREADLOCAL` propagates the
|
||||
context correctly with **zero extra wrapping code**. The reference docs' warning against this
|
||||
mode predates virtual threads and is about pooled platform threads specifically -- it does not
|
||||
apply to this executor shape. This is still a global JVM-wide setting, so weigh that against the
|
||||
next two options, which are scoped to one executor bean.
|
||||
|
||||
## C) DelegatingSecurityContextExecutor -- still works, unconditionally
|
||||
|
||||
`DelegatingSecurityContextExecutor` doesn't rely on thread-local inheritance at all -- it wraps
|
||||
the submitted `Runnable`, and the wrapper explicitly calls
|
||||
`SecurityContextHolder.setContext(...)` / `clearContext()` around the delegate's `run()`,
|
||||
wherever that `run()` happens to execute. That is why it has worked, unchanged, since long
|
||||
before virtual threads existed, and why it is still the correct choice for library code that
|
||||
cannot assume the application has set `MODE_INHERITABLETHREADLOCAL` globally.
|
||||
|
||||
## D) ContextPropagatingTaskDecorator -- the mechanism that's actually new
|
||||
|
||||
This is the one that did not exist when the [original version of this
|
||||
post](https://ankurm.com/spring-security-context-propagation-complete-guide/) went up.
|
||||
Spring Security 6.5 (GA 2025-05-19) added `SecurityContextHolderThreadLocalAccessor`, which
|
||||
self-registers with Micrometer's `ContextRegistry` via `ServiceLoader` the moment
|
||||
`io.micrometer:context-propagation` is on the classpath -- no bean, no configuration. Spring
|
||||
Framework's `ContextPropagatingTaskDecorator` (since 6.1) uses that registry to snapshot and
|
||||
restore every registered `ThreadLocalAccessor` around a task. Set it as the executor's task
|
||||
decorator and `@Async` methods get the `SecurityContext` back **without any
|
||||
`DelegatingSecurityContext*` wrapper at all** -- and the same decorator simultaneously restores
|
||||
MDC and tracing context, which the `Delegating*` classes never touched.
|
||||
|
||||
```java
|
||||
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor();
|
||||
executor.setVirtualThreads(true);
|
||||
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
|
||||
```
|
||||
|
||||
Spring Security's own [Concurrency Support
|
||||
page](https://docs.spring.io/spring-security/reference/features/integrations/concurrency.html)
|
||||
still only documents the `Delegating*` family as of 7.1.1 -- this pattern is real and shipped,
|
||||
just not yet reflected in that page.
|
||||
|
||||
`io.micrometer:context-propagation` is already on the classpath of any Boot 4.1 app that pulls
|
||||
in `micrometer-observation` (actuator, tracing, or `spring-boot-starter-micrometer-*`). If your
|
||||
app doesn't have a Micrometer dependency anywhere, add
|
||||
`io.micrometer:context-propagation:1.2.1` (the version Boot 4.1.1's BOM manages) explicitly.
|
||||
61
docs/03-structured-concurrency.md
Normal file
61
docs/03-structured-concurrency.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 3. StructuredTaskScope and SecurityContext
|
||||
|
||||
[← Prev: Async + virtual threads](02-async-virtual-threads.md)
|
||||
|
||||
`Demo3StructuredConcurrency.java` asks the Chapter 2 question again, but for
|
||||
`StructuredTaskScope` (JEP 505, fifth preview in JDK 25 -- still preview through the JDK 26
|
||||
sixth preview per JEP 525, so every example here needs `--enable-preview`). A `fork()` call
|
||||
starts a brand new virtual thread for the subtask, same as the executors in Chapter 2, so the
|
||||
Chapter 1 finding applies here too. Full output in
|
||||
[`docs/output/demo3.txt`](output/demo3.txt).
|
||||
|
||||
## What the JEP actually promises
|
||||
|
||||
JEP 525's text is explicit about one kind of context and silent about another:
|
||||
|
||||
> Subtasks forked in a scope inherit `ScopedValue` bindings.
|
||||
|
||||
That is a real, specified guarantee -- and it says nothing about `ThreadLocal`. Spring
|
||||
Security's `SecurityContextHolder` is a `ThreadLocal`/`InheritableThreadLocal`, not a
|
||||
`ScopedValue`. Nothing in the structured concurrency API changes that, and scenario A below
|
||||
proves it: a plain `scope.fork(...)` with the default `MODE_THREADLOCAL` strategy loses the
|
||||
`Authentication` exactly like the unwrapped executor in Chapter 2 did.
|
||||
|
||||
## Four scenarios
|
||||
|
||||
- **A) Plain fork, MODE_THREADLOCAL** -- lost. The default `SecurityContextHolder` strategy
|
||||
isn't inherited by anything, structured concurrency included.
|
||||
- **B) Plain fork, MODE_INHERITABLETHREADLOCAL** -- propagates. Same reasoning as Chapter 2,
|
||||
scenario B: `fork()`'s subtask thread is a fresh virtual thread, so inheritance at
|
||||
construction time works and there is no pooled-thread staleness risk.
|
||||
- **C) Manual capture-and-restore around the forked `Callable`** -- propagates, and does not
|
||||
depend on the global strategy mode at all:
|
||||
|
||||
```java
|
||||
SecurityContext captured = SecurityContextHolder.getContext();
|
||||
Callable<String> task = () -> {
|
||||
SecurityContextHolder.setContext(captured);
|
||||
try { return doWork(); }
|
||||
finally { SecurityContextHolder.clearContext(); }
|
||||
};
|
||||
scope.fork(task);
|
||||
```
|
||||
|
||||
This is the safest pattern for a `StructuredTaskScope` used inside library code, the same
|
||||
way `DelegatingSecurityContextExecutor` is the safest pattern for an `Executor`: it works
|
||||
regardless of what the surrounding application has set `SecurityContextHolder`'s strategy to.
|
||||
|
||||
- **D) `ContextSnapshot.wrap(...)` around the forked `Callable`** -- the Chapter 2 mechanism
|
||||
applied to `fork()` instead of `execute()`. Because `SecurityContextHolderThreadLocalAccessor`
|
||||
is already registered with Micrometer's `ContextRegistry`, `ContextSnapshotFactory.builder()
|
||||
.build().captureAll()` picks up the current `SecurityContext` (and MDC, and tracing context)
|
||||
in one call, and `.wrap(callable)` restores all of them inside the subtask. This is the
|
||||
version worth reaching for once you have more than the `SecurityContext` to carry across the
|
||||
scope boundary.
|
||||
|
||||
## The practical takeaway
|
||||
|
||||
`StructuredTaskScope` does not give `SecurityContextHolder` anything for free. If your
|
||||
`fork()`ed subtasks need to call secured services, wrap them explicitly -- option C if you
|
||||
want zero new dependencies, option D if `context-propagation` is already on the classpath and
|
||||
you have other thread-locals to carry along too.
|
||||
5
docs/output/demo1.txt
Normal file
5
docs/output/demo1.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
=== Demo 1: InheritableThreadLocal across thread models ===
|
||||
fresh platform thread sees: request-A
|
||||
pool thread, task 1, sees: request-B
|
||||
pool thread, task 2 (reused), sees: request-B <-- stale, not request-C
|
||||
fresh virtual thread sees: request-D
|
||||
6
docs/output/demo2.txt
Normal file
6
docs/output/demo2.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
=== Demo 2: @Async-style virtual thread executor + SecurityContext ===
|
||||
A) MODE_THREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): NO AUTHENTICATION (lost) [VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||
B) MODE_INHERITABLETHREADLOCAL, raw SimpleAsyncTaskExecutor(virtual): authenticated as bob [VirtualThread[#26,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||
C) DelegatingSecurityContextExecutor around SimpleAsyncTaskExecutor(virtual): authenticated as carol [VirtualThread[#27,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||
SecurityContextHolderThreadLocalAccessor present: true
|
||||
D) ContextPropagatingTaskDecorator on SimpleAsyncTaskExecutor(virtual), no Delegating* wrapper: authenticated as dave [VirtualThread[#28,vt-1]/runnable@ForkJoinPool-1-worker-1]
|
||||
5
docs/output/demo3.txt
Normal file
5
docs/output/demo3.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
=== Demo 3: StructuredTaskScope.fork() + SecurityContext ===
|
||||
A) plain fork, MODE_THREADLOCAL: NO AUTHENTICATION (lost)
|
||||
B) plain fork, MODE_INHERITABLETHREADLOCAL: authenticated as frank
|
||||
C) manual capture/restore: authenticated as grace
|
||||
D) ContextSnapshot.wrap: authenticated as heidi
|
||||
Reference in New Issue
Block a user