1
0
Files
spring-security-demo/docs/06-scheduled-tasks.md
Ankur 9f950bffa9 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.
2026-08-24 16:49:30 +00:00

88 lines
4.7 KiB
Markdown

# 6. DelegatingSecurityContextTaskScheduler and the synthetic system identity
[← Prev: Reactive context](05-reactive-context.md) | [Next: Servlet filter persistence →](07-servlet-filter-persistence.md)
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`](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:
```java
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](02-async-virtual-threads.md) 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.