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.
4.7 KiB
6. DelegatingSecurityContextTaskScheduler and the synthetic system identity
← Prev: Reactive context | Next: Servlet filter persistence →
Every previous chapter is about carrying somebody's context across a thread or scheduler
boundary. Demo6ScheduledSystemIdentity.java is about the case where that framing breaks
down: a @Scheduled cron trigger has no caller, so there is no Authentication anywhere to
propagate in the first place. Full output in docs/output/demo6.txt.
What DelegatingSecurityContextTaskScheduler actually captures
Before writing this demo, the class's bytecode was read directly (not assumed from the
Javadoc) to answer one question precisely: does the single-argument constructor capture
SecurityContextHolder.getContext() once, when the wrapper is built, or fresh, every time
schedule(...) is called? The constructor itself stores a null SecurityContext field;
wrap(Runnable) -- called synchronously from inside every schedule* method -- passes that
null to DelegatingSecurityContextRunnable.create(...), which resolves null to
SecurityContextHolder.getContext() at that exact call site. So the capture happens per
call to schedule(), on whatever thread makes that call, not once at wrapper-construction
time. Scenario B in the demo proves it against the real class rather than the disassembly:
B1) first schedule() call, caller context = registration-thread-X: authenticated as registration-thread-X
B2) second schedule() call on the SAME wrapper, caller context changed to registration-thread-Y: authenticated as registration-thread-Y
Two schedule() calls on the identical DelegatingSecurityContextTaskScheduler instance,
with the calling thread's SecurityContextHolder changed in between, capture two different
contexts independently. In a real Spring app, ScheduledTaskRegistrar.afterPropertiesSet()
calls schedule() once per @Scheduled method, all during application context refresh on the
startup thread -- which is almost always running with no SecurityContext at all.
Scenario A is that realistic case:
A) schedule() called with NO context present on the caller thread: NO AUTHENTICATION (lost)
Why the post's createSystemContext() pattern exists
There is no "the user" to recover here, so the fix in the post's ScheduledTasks example
doesn't try to propagate anything -- it constructs a brand-new SecurityContext from scratch,
inside the @Scheduled method body, with a narrowly scoped synthetic principal:
private SecurityContext createSystemContext() {
Authentication systemAuth = new UsernamePasswordAuthenticationToken(
"SYSTEM", null, AuthorityUtils.createAuthorityList("ROLE_SYSTEM")
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(systemAuth);
return context;
}
Scenario C runs exactly this pattern and confirms the result end to end:
C) task body sets its own systemContext(), ignoring anything the scheduler wrapper captured: SYSTEM with authorities [ROLE_SYSTEM]
ROLE_SYSTEM here, not an admin role borrowed from somewhere else in the app -- the point of
minting a dedicated identity is that a bug in the scheduled task is bounded by what
ROLE_SYSTEM can do, not by whatever the broadest role in the system happens to be.
Edge case: the synthetic principal isn't "anonymous" to Spring Security
It's tempting to assume a made-up SYSTEM principal with no credentials is somehow a special
or unauthenticated case. It isn't -- AuthenticationTrustResolver has no concept of "system"
at all, and treats it as a completely ordinary authenticated principal:
EDGE) AuthenticationTrustResolver.isAnonymous(systemContext()): false
That matters anywhere the app has authorization rules keyed on isAnonymous() or
isRememberMe() (an .anonymous() matcher in a SecurityFilterChain, for instance) --
createSystemContext()'s output will not match those rules, which is usually what you want,
but is worth confirming rather than assuming for a security-relevant identity.
The Boot 4.1 virtual-thread footnote
spring.threads.virtual.enabled=true also swaps the scheduler backing @Scheduled for a
SimpleAsyncTaskScheduler running virtual threads, the same substitution
Chapter 2 covers for @Async. The "fresh thread every time, no
pooled-worker staleness" reasoning from Chapters 1–2 applies here too, but it changes
nothing about this chapter's actual point: there is still no per-invocation user identity for
any thread model to inherit, because there was never a user in the first place.