fix(sts): key the ADK token cache by acting subject, not session alone (Python) - #2460
fix(sts): key the ADK token cache by acting subject, not session alone (Python)#2460QuentinBisson wants to merge 5 commits into
Conversation
…e (Python) Rebased onto current main; adapts to the RFC 8707 resource/audience constructor params. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
6c8b048 to
e4fb32e
Compare
There was a problem hiding this comment.
Pull request overview
Fixes a correctness/security bug in the Python ADK STS token-propagation plugin where delegated (exchanged) tokens were cached only by session ID, causing shared sessions with multiple acting subjects to reuse the first subject’s exchanged token. This aligns the Python runtime behavior with the Go-side fix and prevents identity collapse across callers within the same session.
Changes:
- Key the token cache by
(session_id, subject_key(subject_token)), wheresubject_keyprefersiss+suband falls back to a hash for opaque/sub-less tokens. - Resolve the subject token before cache lookup in
before_run_callback, and sweep expired entries across the cache inafter_run_callbackto handle per-subject entries. - Update/extend integration tests to validate multi-subject session behavior, issuer scoping, opaque token handling, and fail-closed behavior when no subject token is present.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| python/packages/agentsts-adk/src/agentsts/adk/_base.py | Implements per-subject cache keying, adds subject discriminator logic, adjusts cache lookup + eviction behavior. |
| python/packages/agentsts-adk/tests/test_adk_integration.py | Updates existing assertions to use cache_key(...) and adds new cases covering multi-subject behavior and cache eviction semantics. |
Suppressed comments (1)
python/packages/agentsts-adk/src/agentsts/adk/_base.py:263
- cache_key() now calls the configured get_subject_token callback, which can raise. Since header_provider uses cache_key during tool invocation, an exception here would break tool calls; it’s safer to fail closed (return a key with an empty subject) when subject resolution fails.
session = invocation_context.session
return self._cache_key_for(session.id, self._read_subject_token(session.state))
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # subject's key is derivable here, so sweep every expired entry rather | ||
| # than leaving the other subjects' behind. | ||
| for key in [key for key, entry in self.token_cache.items() if _has_token_expired(entry.expiry)]: | ||
| logger.debug("Removing expired subject token from cache") | ||
| self.token_cache.pop(cache_key, None) | ||
| self.token_cache.pop(key, None) |
There was a problem hiding this comment.
Partly fixed in bc252de. The sweep is now gated on the earliest expiry across the cache, so after_run_callback returns without walking anything until a cached token can actually be evicted; the scan recomputes the earliest expiry as it goes, and inserts keep it up to date.
I kept the sweep global rather than scoping it to the running session: a session-scoped sweep never reaches the entries of sessions that stop running, so those would be retained for the lifetime of the process. Trading unbounded growth for an occasional walk seemed like the wrong side of that.
There was a problem hiding this comment.
Closed out in 6471f57. The gate had a hole: a token with no exp cached a None expiry, _earlier_expiry skipped it, and a cache holding only such entries left _earliest_expiry unset so the sweep never ran. Those entries now get a bounded lifetime, so the gate is always armed and every entry stays evictable.
| subject_token = self._read_subject_token(invocation_context.session.state) | ||
| if not subject_token: | ||
| logger.debug("subject token not found in session state for token propagation") | ||
| return None |
There was a problem hiding this comment.
Fixed in bc252de. _read_subject_token now catches and logs, returning None, so a raising caller-supplied get_subject_token skips propagation instead of aborting the run. One change covers both call sites (before_run_callback and cache_key), and it matches how exchange_token and fetch_actor_token failures are already handled here.
There was a problem hiding this comment.
Also handled for the other caller of the same code path in 6471f57: header_provider reaches _read_subject_token via cache_key on every tool call, and it was dereferencing readonly_context._invocation_context without a guard. It now returns no headers instead of raising into the invocation.
…he sweep A caller-supplied get_subject_token that raises no longer aborts the agent run; token propagation is skipped instead. The expired-token sweep stays global, since scoping it to the running session would keep the entries of sessions that never run again forever, but it is now gated on the earliest expiry in the cache so a growing cache is only walked when there is something to evict. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
…ut a context Keying the cache by (session, subject) multiplies entries per session, so an entry whose token carries no exp claim now pins one slot per caller instead of one per session. Give those a bounded lifetime so every entry stays evictable, which also arms the sweep gate that a None expiry left unset. header_provider runs on every tool call and dereferenced a private attribute of an optional argument; it now returns no headers instead of raising into the invocation. An issuer-less token leaves sub unqualified; those partition by token hash rather than by a key two issuers could both produce. Signed-off-by: Quentin Bisson <quentin@giantswarm.io>
3b8755d to
4e72a3c
Compare
An empty subject identifies no principal, so an entry stored under it would be shared by every credential-less caller in a session. The key helper now yields None for it and the cache paths skip. Signed-off-by: Quentin Bisson <quentin@giantswarm.io>
4e72a3c to
e348494
Compare
Supersedes #2333 (auto-closed by stale-bot; rebase onto current main hit real conflicts with the resource/audience support, so GitHub would not let the original be reopened).
What
Python counterpart to the Go STS fix: a session shared by multiple subjects reused whichever caller's exchanged token was cached first, because the cache was keyed only by session ID.
Keys the cache by
(session_id, subject_key(subject_token)), wheresubject_keyderives a stable per-principal discriminator from the acting bearer's issuer-scopedsubclaim (falling back to a hash of the raw token for opaque/sub-less tokens). The subject token is now resolved before the cache lookup inbefore_run_callback, andafter_run_callbacksweeps all expired entries (not just the acting subject's) since a session can now hold one entry per subject.Closes #2181 (Python side; the Go counterpart is #2459).
Cache lifetime
Keying by
(session_id, subject)holds one entry per caller instead of one persession, so an entry whose token carries no
expclaim would pin a slot percaller for the lifetime of the process. Those entries now get a bounded
lifetime. That also arms the sweep gate, which a
Noneexpiry left unset.header_providerruns on every tool call and dereferenced a private attributeof an optional argument. It now returns no headers instead of raising into the
invocation, which is the same fail-closed handling
_read_subject_tokenalreadyapplies to a raising
get_subject_token.An absent
issleavessubunqualified, so issuer-less tokens partition bytoken hash rather than by a key that two issuers omitting
isscould bothproduce.
Scope
Propagate-only mode (no STS configured) forwards the caller's own token, so this
fix stops one caller's token reaching another caller but does not add exchange,
audience narrowing or an
actclaim to that mode. There is no STS to exchangewith when one is not configured, and a second caller in a session is now a
normal case in both modes rather than something either mode has to refuse.
go-unit-testsis red onmainas well (CRD validation ingo/api/v1alpha2and
go/api/v1alpha3); it is not caused by this diff.