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,61 @@
# 3. StructuredTaskScope and SecurityContext
[← Prev: Async + virtual threads](02-async-virtual-threads.md) | [Next: Executor/ExecutorService wrapping →](04-executor-wrapping.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.