fix(mcp): cache StatefulHTTP JWT admin lookups - #6437
Conversation
bb7c70c to
fec606b
Compare
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for tackling the cold-cache latency regression from #4988 — the AuthCache/batched-lookup path mirrors the primary _auth_jwt flow nicely and should close the ~1s per-request hit described in the issue.
While going through the added enforcement logic I ran into two spots where the new checks don't actually take effect at runtime, plus a couple of smaller things worth a look before merge.
Blocking
1. The new revocation/disabled/team-membership rejections get silently swallowed by the caller.
_normalize_jwt_payload now raises HTTPException(401/403) for a revoked token, a disabled account, strict user-in-DB misses, and stale team membership (all inside this PR's new code). But its only caller, _get_request_context_or_default (streamablehttp_transport.py:2182-2194, unchanged by this PR), wraps the call in:
except Exception as e:
logger.warning("Failed to recover user context in stateful session: %s", e)
user_ctx = {}HTTPException is an Exception, so it gets caught here too — the request falls through to an anonymous user_ctx = {} instead of being rejected. Worse, _should_enforce_streamable_rbac({}) then skips RBAC entirely (no is_authenticated key), so the request proceeds with no permission check rather than the intended 401/403. Could you catch HTTPException separately in the caller and propagate it, so this path actually enforces what it's designed to enforce?
2. require_user_in_db isn't re-checked when the cache already has a user.
if cached_user is None and settings.require_user_in_db and email != platform_admin_email:
raise HTTPException(...)This only fires when cached_user is None. The primary auth path (mcpgateway/auth.py:1714) re-verifies against the DB even when cached_ctx.user is populated:
if cached_ctx.user:
if settings.require_user_in_db:
db_user = await asyncio.to_thread(_get_user_by_email_sync, email)
if db_user is None:
raise HTTPException(...)Would it make sense to mirror that here? Otherwise a deleted user with a still-cached record stays trusted for the cache TTL on this path, diverging from the primary path's guarantee.
Suggestions
3. Team-membership revalidation logic is now duplicated a third time. The same cache-check/DB-query/cache-write pattern already exists in token_scoping.py::_validate_team_membership and in the primary path (auth.py ~5399-5439). This new copy (streamablehttp_transport.py:2344-2369) uses a slightly different error path (HTTPException vs. the primary path's _send_error) even though the message text matches — a small refactor into a shared helper would keep these from drifting apart.
4. Test coverage gap around the caller. All the new tests call _normalize_jwt_payload directly and assert it raises — none exercise it through the actual caller, _get_request_context_or_default, so the swallow-bug in #1 wasn't caught. Worth adding a test that goes through the real fallback path and asserts the request is actually denied, plus a case for #2 (cached user present + require_user_in_db=True + user removed from DB).
Minor
5. .secrets.baseline references tests/performance/benchmark_issue_4988.sh:14, but that file isn't part of this diff (and isn't on main). Looks like a benchmark script that didn't get git add-ed — either include it or drop the stray baseline entry.
Scope-wise, this bundles the perf fix with new security enforcement (revocation/active-user/team-membership checks) that #4988 doesn't ask for — not a blocker, but calling it out since it's what's driving the extra surface area under review here.
No schema/UI changes, nothing else jumped out.
76c5d2e to
d6d5daf
Compare
All valid findings from the reviewers were addressed; finding 6 was left out of scope as requested. |
|
|
7c29130 to
a479f43
Compare
|
gandhipratik203
left a comment
There was a problem hiding this comment.
LGTM! Thanks for the changes!
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for this — the hardening here is genuinely valuable. Bringing the stateful-session fallback in line with the primary _auth_jwt path closes a real gap: before this PR, _normalize_jwt_payload() did nothing but an is_user_admin() lookup, with no revocation check, no is_active check, and no user-existence check at all. Routing team-membership validation through the new shared validate_token_team_membership() helper is also a nice consolidation — the old inline cache-then-DB block in _StreamableHttpAuthHandler was duplicated logic waiting to drift.
One blocking item, then a few optional notes.
Blocking
Cache warm writes a partial user dict into the shared AuthCache
mcpgateway/transports/streamablehttp_transport.py:2344-2360
The new cache-warm in the direct-fallback branch stores a three-field user dict:
CachedAuthContext(
user={
"email": getattr(user_record, "email", email),
"is_admin": bool(getattr(user_record, "is_admin", False)),
"is_active": bool(getattr(user_record, "is_active", True)),
},
...
)Every other writer into this cache stores the full ten-field shape — _get_auth_context_batched_sync() (mcpgateway/auth.py:1202-1213) includes password_hash, full_name, auth_provider, password_change_required, email_verified_at, created_at, and updated_at, and the batched branch a few lines above in this same function (line ~2298) passes that full dict straight through.
Because get_auth_cache() is a process-wide singleton keyed on (email, jti), a subsequent REST request carrying the same token hits the cached-context branch in get_current_user() (mcpgateway/auth.py:1772), calls _user_from_cached_dict() (mcpgateway/auth.py:1289), and gets an EmailUser built from .get(..., default) fallbacks for the seven missing fields:
password_change_required→False, so a user flagged for a forced password change silently isn'tauth_provider→"local", masking an SSO-provisioned identityemail_verified_at→Nonepassword_hash→"",created_at/updated_at→now()
That holds for the remainder of the cache TTL, and which shape wins is a race between whichever path warms the entry first.
Two ways to resolve it, whichever fits better:
- Build the same full dict the batched path does — either by reusing
_get_auth_context_batched_sync()here instead of_get_user_by_email_sync(), or by extracting the dict construction inauth.pyinto a small shared helper (_user_to_cache_dict(user_record)) and calling it from both sites. This keeps the perf win. - Skip the cache warm on this branch entirely and leave the DB round trip. Loses the optimization, but no risk of a partial entry.
A regression test asserting that an entry written by the fallback path round-trips through _user_from_cached_dict() with auth_provider and password_change_required intact would lock this down.
Suggestions (non-blocking)
resolve_session_teams() isn't given preresolved_db_teams in the batched branch
mcpgateway/transports/streamablehttp_transport.py:2374
_get_auth_context_batched_sync() already returns team_ids/team_names one call earlier, but the session-token branch calls:
final_teams = await resolve_session_teams(payload, email, {"is_admin": effective_is_admin})The sibling _StreamableHttpAuthHandler in this same file (lines ~5395-5401) passes preresolved_db_teams=preresolved for exactly this case. Without it, resolve_session_teams() → _resolve_teams_from_db() re-fetches membership that was just retrieved, which cuts against the round-trip reduction this PR is aiming for. Threading the batched team_ids through (where the branch has them) would close that.
Similarly at line 2387: for API/legacy tokens, validate_token_team_membership() does its own cache-then-DB membership resolution, duplicating what the batched context already fetched. Lower priority since it only affects the batched branch, but worth a look if you're optimizing this path.
Log messages lost some triage detail
mcpgateway/transports/streamablehttp_transport.py:5427
The refactor collapsed three distinct log lines — a cached rejection (with the (cached) suffix), a DB rejection naming the specific missing_teams, and a separate cache-hit event — into one generic message. An operator triaging a spike in 403s can no longer tell stale-cache rejections from genuine membership changes, or see which team IDs mismatched. If validate_token_team_membership() could return or surface the missing set (or the cache outcome already flowing through on_cache_event), the caller could restore that detail.
Notes — no change requested
A couple of things that looked suspicious on first read but check out, recorded so they don't get re-raised:
-
if email == platform_admin_email: db_user_is_admin = True(line 2363) reads like an unconditional admin override that ignores a DBis_admin=False, and it does diverge fromget_current_user(), which only bootstraps platform-admin when no DB row exists. But it faithfully reproduces the behavior of theis_user_admin()call it replaces —mcpgateway/utils/admin_check.py:83-85returnsTrueon the platform-admin email with no DB check at all. No behavior change here; if that semantic is worth revisiting it belongs in its own change againstadmin_check.py. Same reasoning covers thegetattr(settings, "platform_admin_email", "")empty-string default at line 2250 — it matches the helper rather than the other call sites in this file. -
The
require_user_in_dbgating at lines 2266, 2289, and 2334 doesn't fully match the primary path, which rejects an unknown user with 401 regardless of that setting (mcpgateway/auth.py:2132-2137), so a valid JWT for a deleted account still authenticates here whenREQUIRE_USER_IN_DB=false. Since the pre-PR code had no existence check whatsoever, this is a clear improvement rather than a regression — flagging it only so the remaining parity gap is a deliberate choice rather than an oversight. Happy to see it as follow-up work.
Finding: Cache warm writes partial user dict into shared AuthCacheStatus: Fixed ProblemThe direct-fallback branch in
Which shape won the cache was a race between whichever code path warmed the entry first for a given FixExpanded the 3-field dict to the full 10-field shape, matching the batched-path writer in TestAdded
|
Closes #4988 Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Enforce revocation, account-state, and team-membership checks while preserving cached and batched auth lookups.\n\nCloses #4988 Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Propagate fallback authentication failures, revalidate cached users in strict mode, and centralize team-membership checks. Refs #4988 Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
…ack cache token_scoping: _check_team_membership created a DB session before calling validate_token_team_membership, which checks cache first and may return without touching the DB. Drop the wrapper; the shared helper manages its own session on cache miss. streamablehttp: _normalize_jwt_payload direct-lookup branch (cache enabled, batch queries off, cache miss) never wrote to auth cache, keeping it permanently cold. Add set_auth_context after successful user lookup, mirroring the primary path. Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
The _normalize_jwt_payload cache writer only stored email, is_admin, and is_active, dropping auth_provider, password_change_required, full_name, password_hash, and timestamp fields. When _user_from_cached_dict later reconstructed the user from the partial dict, it silently applied wrong defaults — masking SSO provider identity and skipping forced password changes. Add the missing 7 fields to the cache dict construction and regression tests documenting the full-shape contract. Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
bf3d96c to
36410b5
Compare
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for turning this around — the blocking item from the last round is resolved and I'm happy with where this landed.
Verified fixed
Partial user dict in the cache warm (streamablehttp_transport.py:2338-2362) — the fallback branch now writes the full ten-field shape (password_hash, full_name, auth_provider, password_change_required, email_verified_at, created_at, updated_at all present), matching _get_auth_context_batched_sync(). A REST request that later hits the cached-context branch and rebuilds via _user_from_cached_dict() no longer silently picks up password_change_required=False / auth_provider="local" defaults. Whichever path warms the entry first, the shape is now the same.
Also confirming the earlier round is fully addressed:
except HTTPException: raiseadded at both_get_request_context_or_defaulthandlers (lines 2189, 2200), so the new 401/403 rejections actually propagate instead of falling through to an anonymoususer_ctx = {}.require_user_in_dbis now re-verified against the DB even on a cache hit, matchingauth.py:1714.- Team-membership revalidation consolidated into the shared
validate_token_team_membership()helper — three copies down to one, andtoken_scoping.pysheds 56 lines for it. - The stray
tests/performance/benchmark_issue_4988.shentry is gone from.secrets.baseline.
Test suites for the three touched modules pass clean, and CI is green (31 success / 5 skipped).
Re-checked and cleared — recording so these don't get re-raised
I took another pass specifically to confirm a few things that read as suspicious but aren't:
-
if email == platform_admin_email: db_user_is_admin = True(line ~2371) still looks like an unconditional override of a DBis_admin=False. I checked this empirically rather than by inspection this time: I ran the same payload (platform-admin email, DB record withis_admin=False) through_normalize_jwt_payloadon both this branch and a cleanmainworktree, and both returnis_admin=True. The reason isis_user_admin()— the helper this line replaces — has an explicit no-DB-round-trip fast path atmcpgateway/utils/admin_check.py:83-85. So this is behavior-preserving, not new. Worth noting the natural comparison toauth.py:1899/2125doesn't apply: those are the virtual-user-creation bootstrap sites, a different concern from the visibility fast path. If that semantic deserves revisiting, it's a change againstadmin_check.pyand affects every caller, not just this path. -
_check_token_revoked_syncat line 2314 isn't individually wrapped, so a transient DB error there unwinds to the outer handler and yields an empty context. Pre-PR the same position had an unwrappedis_user_admin()call under the same handler, so the shape is unchanged — and with theexcept HTTPException: raiseadded above, genuine 401/403s now propagate correctly, which is a net improvement. Failing closed to anonymous on a DB error seems defensible.
Optional follow-ups (not blocking)
resolve_session_teams()at line ~2381 still doesn't receivepreresolved_db_teamseven though the batched branch hasteam_idsin hand one call earlier (auth.py:1269), and the sibling path passes it (auth.py:1826-1827). Costs a redundant membership query on that branch.- The generic
"no longer member of teams"warning still drops the specific missing team IDs the old code logged. - The fix above is in place but there's no test asserting a fallback-warmed entry round-trips through
_user_from_cached_dict()withauth_provider/password_change_requiredintact — would be a cheap guard against regression.
Approving. Nice cleanup on the shared helper.
Fixes #4988
Problem
The StreamableHTTP stateful-session fallback called
_normalize_jwt_payload()for requests without propagated context. Each call opened a newSessionLocal()foris_user_admin(), defeating the session-scoped memoization and adding a synchronous database lookup to the request path.Fix
AuthCachebefore opening a database session.