1
0
Files
spring-security-demo/context-propagation/docs/06-scheduled-tasks.md
asmhatre 5e9e7f1b12 Split into per-article modules and add the method-security module
Moves the existing virtual-thread/context-propagation project into
context-propagation/ and adds method-security/ for the Spring Security 7
method-security article: nine runnable demos, fourteen assertions, and every
transcript the article quotes, regenerated by scripts/run-all.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
2026-08-25 02:01:29 +00:00

4.7 KiB
Raw Blame History

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