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
This commit is contained in:
87
context-propagation/docs/06-scheduled-tasks.md
Normal file
87
context-propagation/docs/06-scheduled-tasks.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user