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:
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.
|
||||
Reference in New Issue
Block a user