Skip to content

feat(auth): add team scope support for session tokens#3217

Merged
crivetimihai merged 10 commits intomainfrom
feat-3003-team-scope-for-session-tokens
Mar 23, 2026
Merged

feat(auth): add team scope support for session tokens#3217
crivetimihai merged 10 commits intomainfrom
feat-3003-team-scope-for-session-tokens

Conversation

@crivetimihai
Copy link
Copy Markdown
Member

Note: This PR was re-created from #3012 due to repository maintenance. Your code and branch are intact. @KKNithin please verify everything looks good.

🔗 Related Issue

Closes #3003


📝 Summary

When teams are part of claims in session tokens, use it to as token_teams this helps users to use team tokens functionality which they usually get when they create API tokens scoped to a particular team.


🏷️ Type of Change

  • Bug fix
  • Feature / Enhancement
  • Documentation
  • Refactor
  • Chore (deps, CI, tooling)
  • Other (describe below)

@crivetimihai crivetimihai added this to the Release 1.0.0-GA milestone Feb 24, 2026
@crivetimihai crivetimihai added enhancement New feature or request SHOULD P2: Important but not vital; high-value items that are not crucial for the immediate release labels Feb 24, 2026
@KKNithin
Copy link
Copy Markdown
Collaborator

@crivetimihai I confirm that my changes are intact in this PR

@jonpspri jonpspri force-pushed the feat-3003-team-scope-for-session-tokens branch from c45981a to c88acf6 Compare February 27, 2026 10:16
jonpspri
jonpspri previously approved these changes Feb 27, 2026
Copy link
Copy Markdown
Collaborator

@jonpspri jonpspri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval Summary

Reviewed the full changeset (original commits + refactor commit). The final state is clean and correct.

What changed

  • resolve_session_teams() extracted in mcpgateway/auth.py as the single policy point for session-token team resolution. Session tokens always resolve membership from DB/cache, ensuring revoked team memberships take effect immediately.
  • Three call sites (auth.py cached path, auth.py fallback path, token_scoping middleware) now delegate to resolve_session_teams() instead of inlining the logic or importing the private _resolve_teams_from_db.
  • Debug comment removed from email_auth.py (hardcoded UUID leftover).
  • Docstring lint fix in main.py (excess Returns on a -> None function).
  • Docs updated in multitenancy.md and rbac.md to document resolve_session_teams() alongside normalize_token_teams().
  • Tests thoroughly cover session token DB resolution (cached + fallback paths), API token embedded teams, legacy tokens, and null/missing/multi-team edge cases.

Security review

  • Session tokens never trust embedded JWT team claims — membership is always current from DB/cache. This prevents stale authorization after team removal.
  • Admin bypass (teams: null + is_admin: true) is correctly routed through _resolve_teams_from_db which returns None.
  • API and legacy tokens continue to use normalize_token_teams() directly, which is the correct behavior for immutable programmatic tokens.

Nits (non-blocking)

  • test_email_auth_router.py fixture fixes (auth_provider, get_teams, password_change_enforcement_enabled) are compatibility fixes for other main changes, not part of #3003. Consider splitting into a separate commit on future PRs for cleaner history.

@crivetimihai
Copy link
Copy Markdown
Member Author

Deep-Dive Review — @KKNithin @jonpspri

Rebased cleanly onto main (6 linear commits, no conflicts). All tests pass (118 auth, 76 token-scoping, 79 email-auth-router). 100% differential test coverage on new lines. E2E verified against running server on localhost:8080.


Finding 1 (Critical): PR does NOT implement issue #3003's user story

Issue #3003 asks:

"When teams are provided in the claims of a session token, use them instead of resolving teams from DB"
User story: "I want to use a session token with teams=[team1] so that I can query resources scoped to team1 only"

What the PR actually does: resolve_session_teams() always resolves from DB and completely ignores the JWT's teams claim. The payload parameter is explicitly unused.

Commit evolution:

  • @KKNithin's commits 1–4: Implemented the feature as requested (trust JWT embedded teams for session tokens, restricted to single-team only in v2)
  • @jonpspri's commit 6: Removed that feature entirely, always DB-resolving. Rationale: "trusting embedded JWT claims could allow stale team scoping until token expiry"

Jonathan's security concern is valid — but the result is that Closes #3003 doesn't deliver the requested feature. A user in team1+team2 cannot narrow their session to team1 only.

Suggested resolution — intersection approach:

Resolve from DB first (ensuring revoked memberships are enforced), then intersect with JWT-embedded teams if present. This gives both the security property and the scope-narrowing user story:

async def resolve_session_teams(payload, email, user_info):
    db_teams = await _resolve_teams_from_db(email, user_info)
    if db_teams is None:  # admin bypass
        return None
    jwt_teams = payload.get("teams")
    if isinstance(jwt_teams, list) and jwt_teams:
        # Narrow to intersection — only teams the user still has in DB
        narrowed = [t for t in db_teams if t in jwt_teams]
        return narrowed if narrowed else db_teams
    return db_teams

This way:

  • Revoked teams are never returned (DB is the authority)
  • Users can narrow their session to a subset of their actual teams
  • If the intersection is empty (all JWT teams revoked), falls back to full DB teams rather than locking the user out

Finding 2 (Medium): "Single policy point" claim has gaps

The docstring claims resolve_session_teams is "the single policy point for session-token team resolution." In practice, 3 of 6 session-token resolution paths bypass it:

Path Uses resolve_session_teams()
auth.py cache path (L1076) Yes
auth.py batched queries (L1138–1145) No — inline from batch result
auth.py fallback (L1271) Yes
token_scoping.py middleware (L1175) Yes
main.py admin middleware (L1762) No — calls _resolve_teams_from_db directly
streamablehttp_transport.py (L1211, L2533) No — calls sync variant

The batched queries path is the default production path for first-time requests (auth_cache_batch_queries=True by default). If policy logic (e.g. the intersection above) is added to resolve_session_teams() later, the batched path would silently bypass it.

Suggested fix: Update main.py:1762 to call resolve_session_teams(). For the batched queries path, either route through resolve_session_teams() after the batch, or add a code comment marking it as a known exception. For streamablehttp_transport.py, a sync companion resolve_session_teams_sync() would be ideal, or at minimum a comment linking to the async version.


Finding 3 (Low): _check_team_membership reads JWT claims, not resolved teams

In token_scoping.py:1194, after resolving teams from DB into token_teams, the middleware calls _check_team_membership(payload) which reads payload.get("teams") — the original JWT claims, not the DB-resolved token_teams.

For normal session tokens (no teams in JWT), this is fine — _check_team_membership gets [] and returns True. But it means there are two different "team" views in the same function: DB-resolved (for scoping) and JWT-based (for membership validation). Not a bug in practice, but a confusing code pattern that could surprise future maintainers.


Finding 4 (Informational): email_auth_router test changes are correct

The added mock attributes (auth_provider, get_teams, team_memberships, password_change_enforcement_enabled) are test maintenance fixes needed because production code on main was updated to access these attributes. Correctly aligned.


Summary

Finding Severity Action needed
PR doesn't implement #3003 user story Critical Decide: intersection approach, or descope and file follow-up
"Single policy point" has gaps (3/6 paths bypass) Medium Update main.py; address batched path
_check_team_membership reads JWT not resolved teams Low Code comment or refactor
email_auth_router test fixes Info None

The code quality, test coverage, and security posture are solid. The core question is whether the PR should actually deliver the feature #3003 requested (scope-narrowing via intersection) or accept the current DB-only approach and file a follow-up.

@crivetimihai
Copy link
Copy Markdown
Member Author

Addendum — cross-verified with Codex

Ran an independent automated review (Codex, read-only) against git diff main. Results:

  • Finding 1 (Critical): Confirmed. resolve_session_teams ignores payload at line 321. Commit history traces the rollback (f4e1e92 added JWT trust → c6ae9fb removed it). Issue [FEATURE][AUTH]: Use team scope from session token claims instead of DB resolution #3003 user story is not delivered.

  • Finding 2 (Medium): Confirmed. Batched queries path (auth.py:1138-1145) is the default production path (auth_cache_batch_queries=True per config.py:1340) and bypasses resolve_session_teams(). main.py:1762 and streamablehttp_transport.py:1219,2544 also bypass it. "Single policy point" claim in the docstring and docs (multitenancy.md:322, rbac.md:141) is not accurate.

  • Finding 3 (Low): Confirmed. _check_team_membership reads payload.get("teams") (JWT) at token_scoping.py:714, not the DB-resolved token_teams. With a crafted JWT teams: ["fake"], the 403 comes from _check_team_membership, not from the DB resolution. Practical impact is low since create_access_token never embeds teams in session tokens.

  • Finding 4 (Informational): Partially confirmed. The auth_provider and password_change_enforcement_enabled / detect_default_password_on_login / require_password_change_for_default_password fixes are correct and necessary. However, get_teams and team_memberships added to the mock user fixture at test_email_auth_router.py:44-45 appear unnecessary — the tested code paths (login returning 403 for password change) return before reaching create_access_token or any code that accesses these attributes. They're harmless but could be removed.

  • Additional — test mock gap: Confirmed. New tests mock _resolve_teams_from_db directly (e.g., test_auth.py:330, test_token_scoping.py:429). These tests would pass even if the code called _resolve_teams_from_db() directly instead of going through resolve_session_teams(). No test asserts that resolve_session_teams itself is called.

Overall assessment (concurred by both reviewers): Requires rework if this PR is meant to close #3003 as written. The security refactoring is solid, but the feature isn't delivered and the docs overstate centralization.

@jonpspri jonpspri force-pushed the feat-3003-team-scope-for-session-tokens branch from 1728ab6 to 5355f42 Compare February 27, 2026 16:42
@KKNithin
Copy link
Copy Markdown
Collaborator

@crivetimihai @jonpspri I am ok with is new architecture of narrowing teams, its actually better than what was implemented and make it more secure.

@jonpspri
Copy link
Copy Markdown
Collaborator

Response to deep-dive review findings

Thanks @crivetimihai for the thorough review and Codex cross-validation. All findings have been addressed across the commit history. Here's the current disposition:


Finding 1 (Critical): PR didn't implement #3003 user story — RESOLVED

The intersection approach you suggested is now implemented in _narrow_by_jwt_teams() (shared helper) called by both resolve_session_teams() and resolve_session_teams_sync(). The policy is:

  1. Always resolve teams from DB first (revoked memberships enforced immediately)
  2. If the JWT carries a non-empty teams claim, intersect with DB teams
  3. If the intersection is empty (all JWT teams revoked), return []fail-closed (public-only scope, not a fallback to full DB teams)
  4. If no JWT teams claim (or null/[]), return full DB membership

This delivers the #3003 user story (a user in team1+team2 can narrow their session to team1 only) while maintaining the security property that revoked memberships take effect immediately.

Semantic note on empty intersection: The original suggested code fell back to full DB teams on empty intersection. We chose fail-closed ([]) instead — if a user's JWT claims teams they've been removed from, they get demoted to public-only scope rather than silently broadened to their full membership. This is the more conservative security posture.


Finding 2 (Medium): "Single policy point" had gaps — RESOLVED

All 6 session-token resolution paths now go through the public API:

Path Function Status
auth.py cache path (L1167) resolve_session_teams() Fixed
auth.py batched queries (L1234) resolve_session_teams(preresolved_db_teams=...) Fixed
auth.py fallback (L1363) resolve_session_teams() Fixed
token_scoping.py middleware (L1175) resolve_session_teams() Fixed
main.py admin middleware (L1762) resolve_session_teams() Fixed
streamablehttp_transport.py (L1217, L2539) resolve_session_teams_sync() Fixed

The batched path passes preresolved_db_teams to skip a redundant DB call while still routing through the intersection policy. The cache stores raw DB teams (not the narrowed intersection) so multiple sessions for the same user narrow independently.


Finding 3 (Low): _check_team_membership reads JWT not resolved teams — RESOLVED

Session tokens now skip _check_team_membership entirely in the token scoping middleware (both token_teams and public-only branches have token_use != "session" guards). Since resolve_session_teams() already resolved membership from the DB, re-checking the raw JWT claim would be both redundant and incorrect (it would conflict with the intersection semantics). Membership staleness is bounded by the auth_cache TTL.


Finding 4 (Informational): email_auth_router test changes — acknowledged

No action taken; these are compatibility fixes for main changes as noted.


Additional: test mock gap (no test asserted resolve_session_teams is called) — RESOLVED

test_session_token_calls_resolve_session_teams in test_token_scoping.py directly mocks and asserts that resolve_session_teams is the function called by the middleware, not _resolve_teams_from_db directly. Additional direct unit tests for _narrow_by_jwt_teams cover edge cases (admin bypass, empty intersection, malformed entries, teams: null, teams: []).


Documentation updates

  • multitenancy.md: Separate tables for API/legacy vs session token scoping, intersection semantics, cache isolation, staleness admonition
  • rbac.md: Matching session token resolution table, pipeline step updated, staleness admonition
  • securing.md: Updated "Session Tokens vs API Tokens" note to mention JWT intersection narrowing

jonpspri
jonpspri previously approved these changes Feb 28, 2026
Copy link
Copy Markdown
Collaborator

@jonpspri jonpspri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval

All findings from the deep-dive review have been addressed. The final state is clean, correct, and well-documented.

What was verified

  • DB-first intersection policy delivers the #3003 user story while maintaining security (revoked memberships enforced immediately, fail-closed on empty intersection)
  • Single policy point claim is now accurate — all 6 session-token resolution paths route through resolve_session_teams() / resolve_session_teams_sync()
  • _check_team_membership bypass for session tokens eliminates the JWT-vs-DB mismatch concern; staleness bounded by auth_cache TTL
  • Cache isolation stores raw DB teams, not per-session narrowed intersection, preventing cross-session cache poisoning
  • Tests cover intersection logic, empty intersection fail-closed, cache poisoning regression (with narrowing payload), admin bypass, and direct _narrow_by_jwt_teams edge cases (218 tests pass)
  • Documentation in multitenancy.md, rbac.md, and securing.md accurately reflects all behavioral changes including session token scoping tables, teams: [] semantics, and cache TTL staleness

@jonpspri jonpspri force-pushed the feat-3003-team-scope-for-session-tokens branch 3 times, most recently from fd9ec62 to f07b2c2 Compare March 1, 2026 17:03
@crivetimihai crivetimihai added the merge-queue Rebased and ready to merge label Mar 3, 2026
@jonpspri jonpspri force-pushed the feat-3003-team-scope-for-session-tokens branch 3 times, most recently from 45249bb to 357c39e Compare March 5, 2026 20:59
jonpspri
jonpspri previously approved these changes Mar 6, 2026
Copy link
Copy Markdown
Collaborator

@jonpspri jonpspri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Approve

Reviewed the full diff against main, ran all relevant test suites (auth, token scoping, streamable HTTP transport, main extended, email auth router — all pass), confirmed ruff clean on changed production files, and verified single Alembic head.

What this delivers

Implements #3003: session tokens now support team scoping via a DB-first intersection approach. resolve_session_teams() is the single async policy point — the database is always authoritative, and the JWT teams claim only narrows (never broadens) the result. Fail-closed on empty intersection (all JWT-claimed teams revoked → public-only scope).

Key design decisions (all sound)

  • All 6 session-token resolution paths centralized through resolve_session_teams()
  • Session tokens skip _check_team_membership in the middleware (DB resolution makes it redundant; staleness bounded by auth_cache TTL)
  • Cache stores raw DB teams, not the narrowed intersection, preventing cross-session cache poisoning
  • _resolve_teams_from_db_sync removed; _normalize_jwt_payload made async to use the shared policy point

No blocking findings

Two low/info items noted (async-only _normalize_jwt_payload, silent degradation UX on empty intersection) — neither requires action before merge.

@jonpspri jonpspri added the release-fix Critical bugfix required for the release label Mar 6, 2026
@crivetimihai crivetimihai self-assigned this Mar 7, 2026
@jonpspri jonpspri force-pushed the feat-3003-team-scope-for-session-tokens branch 4 times, most recently from f8f807f to f4318a5 Compare March 14, 2026 07:34
@jonpspri jonpspri force-pushed the feat-3003-team-scope-for-session-tokens branch 2 times, most recently from 5cd1825 to 0368a66 Compare March 23, 2026 09:59
@jonpspri jonpspri added MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe internal-sari Internal Project Partner - Codename Sari and removed SHOULD P2: Important but not vital; high-value items that are not crucial for the immediate release labels Mar 23, 2026
@jonpspri jonpspri force-pushed the feat-3003-team-scope-for-session-tokens branch from 0368a66 to bbaa0a0 Compare March 23, 2026 10:34
Nithin Katta and others added 7 commits March 23, 2026 10:50
Signed-off-by: Nithin Katta <Nithin.Katta@ibm.com>
Signed-off-by: Nithin Katta <Nithin.Katta@ibm.com>
…only

Signed-off-by: Nithin Katta <Nithin.Katta@ibm.com>
…ion tokens

Signed-off-by: Nithin Katta <Nithin.Katta@ibm.com>
Signed-off-by: Nithin Katta <Nithin.Katta@ibm.com>
…coping

Implement resolve_session_teams() as the single policy point for
session-token team resolution.  Teams are always resolved from the
database first so that revoked memberships take effect immediately.
If the JWT carries a non-empty teams claim, the result is narrowed to
the intersection of DB teams and JWT teams — letting callers scope a
session to a subset of their memberships without risking stale grants.

All six session-token resolution paths (auth.py cache, batched, and
fallback; token_scoping middleware; AdminAuthMiddleware; streamablehttp
transport) now route through this function.  Session tokens skip
_check_team_membership re-validation since resolve_session_teams()
already resolved membership from the DB.

Remove _resolve_teams_from_db_sync (replaced by async-only path).
Regenerate code2flow.svg and remove unused test mock attributes.

Closes #3003

Signed-off-by: Jonathan Springer <jps@s390x.com>
The warning box previously stated admin bypass requires `teams: null`
without qualifying that this applies only to API/legacy tokens. This
contradicted the session token table directly above it, which correctly
shows admin bypass is DB-derived regardless of JWT teams.

Split the warning into API/legacy vs session token sections to eliminate
the contradiction.

Closes #3003

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
The token scoping quick reference only described API/legacy token
behavior. Add the session-token table and key behaviors so the
project guidance reflects the dual-model introduced by #3003.

Also note that token scoping (Layer 1) is independent of RBAC
(Layer 2) — session-token narrowing controls visibility, not which
team roles are checked for permissions. See #3799 for the Layer 2
follow-up.

Closes #3003

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Fix remaining stale references that described admin bypass without
distinguishing API/legacy tokens from session tokens:

- AGENTS.md:112 — security invariant now references both
  normalize_token_teams() and resolve_session_teams()
- multitenancy.md:1247 — enforcement summary now qualifies admin
  bypass per token type
- rbac.md:246 — "Explicit Admin Bypass" section split by token type
- rbac.md:639 — scoping strategy table corrected for session tokens

Closes #3003

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Lines 1246 and 1248 described missing/empty teams behavior without
distinguishing API/legacy from session tokens. For session tokens,
missing/null/empty teams returns full DB membership (not public-only).

Closes #3003

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai crivetimihai force-pushed the feat-3003-team-scope-for-session-tokens branch from bbaa0a0 to 9069a0a Compare March 23, 2026 13:44
@crivetimihai
Copy link
Copy Markdown
Member Author

Review Changes

Rebased onto main (no conflicts) and made the following changes:

Commits added

  1. 7875328ec — Removed AI co-author reference from Jonathan Springer's commit (amend only — removed Co-Authored-By: Claude Opus 4.6 line, no code change).

  2. 4cae8a55ddocs(rbac): clarify admin bypass requirements per token type
    Split the admin bypass warning in rbac.md into API/legacy vs session token sections. The previous wording said admin bypass requires teams: null without qualifying that this only applies to API/legacy tokens — contradicting the session token table directly above it.

  3. ef2360341docs(AGENTS.md): document session-token scoping contract
    Added session token table and key behaviors to the project guidance (AGENTS.md). Updated the security invariant to reference both normalize_token_teams() and resolve_session_teams(). Added explicit note that session-token narrowing is Layer 1 (visibility) only.

  4. 4c3c910e8docs: qualify all admin bypass statements by token type
    Fixed remaining stale references in rbac.md ("Explicit Admin Bypass" section, scoping strategy table) that described admin bypass without the API/legacy vs session token distinction.

  5. 9069a0a74docs(multitenancy): qualify teams-key behavior by token type
    Fixed multitenancy.md enforcement summary: "Missing teams key always returns []" and "Empty teams [] results in public-only" are only true for API/legacy tokens; session tokens return full DB membership for missing/null/empty teams. Also fixed one more instance in rbac.md Security Design Principles section.

No code changes

All original code by @KKNithin and @jonpspri is unchanged. The changes are documentation-only.

Follow-up issue

Filed #3799 to track narrowing RBAC Layer 2 permission checks to match session-token team scope (pre-existing behavior, out of scope for this PR).

Test results

All 1407 tests pass across all affected test files.

Copy link
Copy Markdown
Member Author

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Approve (cannot self-approve via GitHub)

Code & Security: The DB-first intersection model is well-designed. resolve_session_teams() correctly centralizes all 6 session-token resolution paths. Admin bypass is properly gated. JWT teams can only narrow (never broaden) session scope. Fail-closed on empty intersections. No privilege escalation vectors found.

Tests: Comprehensive coverage — TestResolveSessionTeams (10 tests), TestNarrowByJwtTeams (8 tests), plus integration tests in middleware and transport layers. All branches exercised.

Docs: All admin bypass statements now qualified by token type. Session-token contract documented in AGENTS.md.

Known limitation (out of scope): RBAC Layer 2 check_any_team aggregation is not narrowed by token_teams — tracked in #3799.

@crivetimihai crivetimihai merged commit 0676a7d into main Mar 23, 2026
39 checks passed
@crivetimihai crivetimihai deleted the feat-3003-team-scope-for-session-tokens branch March 23, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request internal-sari Internal Project Partner - Codename Sari merge-queue Rebased and ready to merge MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe release-fix Critical bugfix required for the release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE][AUTH]: Use team scope from session token claims instead of DB resolution

3 participants