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:
131
README.md
Normal file
131
README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# spring-security-demo
|
||||
|
||||
Companion repo for [Spring Security Context Propagation: The Complete
|
||||
Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/) on ankurm.com.
|
||||
Eight small, dependency-light programs and one JUnit test suite that answer one question each:
|
||||
does a Spring Security `SecurityContext` survive a specific thread, scheduler, or request
|
||||
hand-off? Every scenario sets an `Authentication` somewhere and checks whether the other side
|
||||
of the hand-off can see it -- against real executors, a real Reactor pipeline, a real
|
||||
`TaskScheduler`, and real servlet filter classes (via Spring Test's mock request/response, no
|
||||
running server needed).
|
||||
|
||||
Every example the post shows -- `@Async`, `ExecutorService`, `CompletableFuture`, virtual
|
||||
threads, `StructuredTaskScope`, `ReactiveSecurityContextHolder`/WebFlux,
|
||||
`DelegatingSecurityContextTaskScheduler` and scheduled tasks, and the
|
||||
`SecurityContextHolderFilter`/`SecurityContextPersistenceFilter` servlet distinction -- has a
|
||||
runnable demo here, plus edge cases the post doesn't have room for. See the [edge-case
|
||||
index](docs/08-testing-contract.md#edge-case-index) for the full list with links.
|
||||
|
||||
## Verified versions
|
||||
|
||||
| Component | Version |
|
||||
|---|---|
|
||||
| JDK | 25 (Temurin 25.0.4.1+1), LTS, GA 2025-09-16 |
|
||||
| Spring Boot (reference target) | 4.1.1 |
|
||||
| Spring Framework | 7.0.9 |
|
||||
| Spring Security | 7.1.1 |
|
||||
| Spring Security Test | 7.1.1 |
|
||||
| `io.micrometer:context-propagation` | 1.2.1 (as managed by Boot 4.1.1's BOM) |
|
||||
| Reactor Core / Reactor Test | 3.8.7 (as managed by Boot 4.1.1's `reactor-bom` 2025.0.7) |
|
||||
| `jakarta.servlet-api` | 6.1.0 |
|
||||
| JUnit Jupiter | 6.0.3 |
|
||||
| AssertJ | 3.27.7 |
|
||||
|
||||
`StructuredTaskScope` is a **preview API** on JDK 25 (JEP 505, fifth preview) and remains
|
||||
preview through JDK 26 (JEP 525, sixth preview) -- every build/run/test command below needs
|
||||
`--enable-preview`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
|
||||
javac --release 25 --enable-preview -cp "$(cat cp.txt)" -d target/classes $(find src/main -name '*.java')
|
||||
java --enable-preview -cp "target/classes:$(cat cp.txt)" com.ankurm.vt.Demo1PlainThreadLocal
|
||||
```
|
||||
|
||||
Or just run everything -- all seven demos plus the test suite -- and regenerate the captured
|
||||
output: `scripts/run-all.sh`. To run only the JUnit contract tests: `mvn test` (the
|
||||
`--enable-preview` flag is already wired into `pom.xml`'s surefire `argLine`, no extra flags
|
||||
needed).
|
||||
|
||||
## What each demo shows
|
||||
|
||||
| Demo | Question | Chapter |
|
||||
|---|---|---|
|
||||
| `Demo1PlainThreadLocal` | Does `InheritableThreadLocal` behave differently for a pooled platform thread vs. a fresh virtual thread? (No Spring.) | [docs/01](docs/01-inheritable-threadlocal.md) |
|
||||
| `Demo2AsyncVirtualThreads` | Does the Boot-4.1-style virtual-thread `@Async` executor propagate `SecurityContext`, and what four fixes change? | [docs/02](docs/02-async-virtual-threads.md) |
|
||||
| `Demo3StructuredConcurrency` | Does a `StructuredTaskScope.fork()` subtask see the parent's `SecurityContext`? | [docs/03](docs/03-structured-concurrency.md) |
|
||||
| `Demo4ExecutorWrapping` | Do `DelegatingSecurityContextExecutorService`/`Executor`/`AsyncTaskExecutor` propagate context on a classic *pooled platform-thread* executor, and does a reused worker leak between tasks the way `InheritableThreadLocal` did in Demo 1? | [docs/04](docs/04-executor-wrapping.md) |
|
||||
| `Demo5ReactiveContext` | Does `ReactiveSecurityContextHolder` survive a scheduler hop that kills plain `ThreadLocal`-based `SecurityContextHolder`? | [docs/05](docs/05-reactive-context.md) |
|
||||
| `Demo6ScheduledSystemIdentity` | What does `DelegatingSecurityContextTaskScheduler` actually capture, and when -- and how does the post's `createSystemContext()` pattern fix the fact that there's no real caller to propagate from? | [docs/06](docs/06-scheduled-tasks.md) |
|
||||
| `Demo7ServletFilterPersistence` | Does `SecurityContextHolderFilter` really never save, while `SecurityContextPersistenceFilter` does -- proven against real filter instances and a real `HttpSession`? | [docs/07](docs/07-servlet-filter-persistence.md) |
|
||||
| `SecurityContextPropagationContractTest` (JUnit, `src/test`) | Same ten claims above, pinned as assertions instead of printed lines; includes a `TestSecurityContextHolder`-based test reproducing the post's own "Testing Security Context Propagation" section | [docs/08](docs/08-testing-contract.md) |
|
||||
|
||||
## Endpoints / entry points
|
||||
|
||||
There's no web server in this repo (see the top of this file), so "entry points" means: every
|
||||
class above has a runnable `main()`, and the whole suite runs end to end via
|
||||
`scripts/run-all.sh`. `Demo5ReactiveContext` reproduces the post's `/profile`
|
||||
(`ReactiveSecurityContextHolder`) behavior as a plain `Mono` chain rather than a bound HTTP
|
||||
route, and `Demo7ServletFilterPersistence` reproduces the filter chain's request-scoped
|
||||
behavior against `MockHttpServletRequest`/`MockHttpServletResponse` rather than a bound
|
||||
servlet container -- both keep the "no web server, no HTTP" property the original three demos
|
||||
established, so the whole repo still runs in well under a second with zero open ports.
|
||||
|
||||
## Captured output
|
||||
|
||||
Every number and log line in the blog post traces back to one of these, produced by
|
||||
`scripts/run-all.sh`, not retyped:
|
||||
|
||||
- [docs/output/demo1.txt](docs/output/demo1.txt)
|
||||
- [docs/output/demo2.txt](docs/output/demo2.txt)
|
||||
- [docs/output/demo3.txt](docs/output/demo3.txt)
|
||||
- [docs/output/demo4.txt](docs/output/demo4.txt)
|
||||
- [docs/output/demo5.txt](docs/output/demo5.txt)
|
||||
- [docs/output/demo6.txt](docs/output/demo6.txt)
|
||||
- [docs/output/demo7.txt](docs/output/demo7.txt)
|
||||
- [docs/output/tests.txt](docs/output/tests.txt) -- `mvn test` surefire summary for the ten
|
||||
contract tests
|
||||
|
||||
## Doc chapters
|
||||
|
||||
Numbered, cross-linked, each with prev/next navigation at the top:
|
||||
|
||||
1. [InheritableThreadLocal across thread models](docs/01-inheritable-threadlocal.md)
|
||||
2. [@Async, DelegatingSecurityContextExecutor, and virtual threads](docs/02-async-virtual-threads.md)
|
||||
3. [StructuredTaskScope and SecurityContext](docs/03-structured-concurrency.md)
|
||||
4. [Executor, ExecutorService, and AsyncTaskExecutor wrapping](docs/04-executor-wrapping.md)
|
||||
5. [ReactiveSecurityContextHolder and Reactor Context](docs/05-reactive-context.md)
|
||||
6. [DelegatingSecurityContextTaskScheduler and the synthetic system identity](docs/06-scheduled-tasks.md)
|
||||
7. [SecurityContextHolderFilter vs. SecurityContextPersistenceFilter](docs/07-servlet-filter-persistence.md)
|
||||
8. [Testing contract + edge-case index](docs/08-testing-contract.md)
|
||||
|
||||
## Edge cases
|
||||
|
||||
Thirteen reproducible edge cases were found building this repository -- pooled-worker leaks
|
||||
the `Delegating*` classes don't have, the common `ForkJoinPool` trap, why
|
||||
`MODE_INHERITABLETHREADLOCAL` is a JVM-wide instrument, why there's no
|
||||
`DelegatingSecurityContextStructuredTaskScope` and never will be, a real `NullPointerException`
|
||||
hit writing the reactive test, `ReactiveSecurityContextHolder` completing empty rather than
|
||||
erroring, per-call (not per-construction) context capture in
|
||||
`DelegatingSecurityContextTaskScheduler`, why a synthetic `SYSTEM` principal isn't
|
||||
"anonymous", and the precise load-vs-save split between the two servlet filters. Full list,
|
||||
each with the chapter that reproduces it: [docs/08 § Edge-case
|
||||
index](docs/08-testing-contract.md#edge-case-index).
|
||||
|
||||
## The one-line summary of all eight chapters
|
||||
|
||||
`SecurityContextHolder` is a `ThreadLocal`. Nothing about virtual threads, structured
|
||||
concurrency, reactive streams, schedulers, or servlet filters changes that fact -- what changes
|
||||
between them is *how* (or whether) anything carries that `ThreadLocal`'s value across the
|
||||
boundary each one introduces. Virtual threads are never pooled, so
|
||||
`MODE_INHERITABLETHREADLOCAL`'s old danger (stale context on a reused pool worker) doesn't
|
||||
apply to them, but it's still a JVM-wide setting. The `Delegating*` wrapper family solves the
|
||||
same pooled-worker problem by a completely different mechanism -- explicit push/pop per task,
|
||||
never thread inheritance -- which is why it has worked, unchanged, since long before virtual
|
||||
threads existed. Reactive code doesn't have a `ThreadLocal`-compatible thread to begin with, so
|
||||
`ReactiveSecurityContextHolder` uses Reactor's own `Context` instead. Scheduled tasks have no
|
||||
caller at all, so the fix isn't propagation, it's minting an identity. And the servlet filter
|
||||
that used to auto-save the context for you was replaced by one that only loads -- a change
|
||||
worth knowing about before it's the reason a custom filter's write silently doesn't survive to
|
||||
the next request.
|
||||
Reference in New Issue
Block a user