1
0

Add every example from the post, plus edge cases, to the companion repo

Full companion repo for the ankurm.com post "Spring Security Context Propagation:
The Complete Guide" -- every code example the post discusses now has a corresponding
runnable, verified demo (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency
versions), not just the virtual-thread/structured-concurrency sections:

- Demo1PlainThreadLocal: InheritableThreadLocal across thread models (no Spring)
- Demo2AsyncVirtualThreads: @Async on a virtual-thread SimpleAsyncTaskExecutor
  (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator)
- Demo3StructuredConcurrency: StructuredTaskScope.fork() propagation
- Demo4ExecutorWrapping: DelegatingSecurityContextExecutorService/Executor/
  AsyncTaskExecutor on a classic pooled platform-thread executor -- the post's
  "Using @Async" / "Using ExecutorService" / "Using CompletableFuture" sections
- Demo5ReactiveContext: ReactiveSecurityContextHolder vs. ThreadLocal across a
  Reactor scheduler hop -- the post's WebFlux/getProfile() section
- Demo6ScheduledSystemIdentity: DelegatingSecurityContextTaskScheduler's actual
  per-call capture semantics (confirmed via bytecode before writing the demo) and
  the createSystemContext() pattern -- the post's scheduled-tasks section
- Demo7ServletFilterPersistence: SecurityContextHolderFilter (load-only) vs.
  SecurityContextPersistenceFilter (load+auto-save), against real filter instances
  and a real HttpSession -- the post's servlet-environment section
- SecurityContextPropagationContractTest: 10 JUnit tests pinning the above as
  assertions instead of printed lines, including a TestSecurityContextHolder-based
  test reproducing the post's own "Testing Security Context Propagation" section

Thirteen edge cases discovered along the way are indexed in docs/08 with links into
the chapter that reproduces each one -- a reused pool worker NOT leaking under the
Delegating* wrappers (unlike Demo1's InheritableThreadLocal), the common ForkJoinPool
trap, why there's no DelegatingSecurityContextStructuredTaskScope and never will be,
a real NullPointerException from Reactor's map() hit while writing the reactive test,
per-call (not per-construction) context capture in DelegatingSecurityContextTaskScheduler,
and the precise load-vs-save split between the two servlet filters, among others.

docs/01-08 are numbered, cross-linked chapters with prev/next navigation; README
indexes all demos, chapters, captured output, and the edge-case list. scripts/run-all.sh
regenerates every docs/output/*.txt and the test suite output from one command.
This commit is contained in:
2026-08-24 21:48:47 +05:30
committed by Claude
commit 9f950bffa9
28 changed files with 2009 additions and 0 deletions

View 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.