Skip to content

Commit 70bd627

Browse files
committed
feat(#3003): implement DB-first intersection for session token team scoping
Implement the intersection approach for session token team resolution: DB teams are always resolved first (revoked memberships enforced immediately), then narrowed to the intersection with any JWT-embedded teams claim. This delivers the #3003 user story — users can scope a session to a subset of their memberships — without risking stale grants. Key changes: - resolve_session_teams() now applies intersection policy with preresolved_db_teams support for the batched queries path - Add resolve_session_teams_sync() for StreamableHTTP transport - Route all 6 session-token call sites through the policy point - Skip _check_team_membership for session tokens (DB resolution already validates; raw JWT check conflicts with fallback semantics) - Cache raw DB teams (not narrowed intersection) to prevent cross-session cache poisoning - Document intersection behavior and empty-intersection fallback Closes #3003 Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent eb05c44 commit 70bd627

File tree

10 files changed

+367
-60
lines changed

10 files changed

+367
-60
lines changed

docs/docs/architecture/multitenancy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ Token scoping is the mechanism that determines which resources a user can **see*
319319
**Key functions** in `mcpgateway/auth.py`:
320320

321321
- `normalize_token_teams()` — The **single source of truth** for interpreting JWT team claims into a canonical form. Used directly by API tokens and legacy tokens.
322-
- `resolve_session_teams()` — The **single policy point** for session-token team resolution. Session tokens (`token_use: "session"`) always resolve teams from the database via `_resolve_teams_from_db()` so that revoked memberships take effect immediately.
322+
- `resolve_session_teams()` / `resolve_session_teams_sync()` — 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 `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. If the intersection is empty (e.g. all JWT-claimed teams have been revoked), the full DB team list is returned as a fallback so the user is not locked out.
323323
- API tokens and legacy tokens always use `normalize_token_teams()` directly.
324324

325325
### Secure-First Defaults

docs/docs/manage/rbac.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Protected entities:
138138

139139
## Token Scoping Model
140140

141-
Token scoping controls what resources a token can access based on the `teams` claim in the JWT payload. The `normalize_token_teams()` function is the **single source of truth** for interpreting JWT team claims into a canonical form. For session tokens, `resolve_session_teams()` is the **single policy point** — it always resolves teams from the database so that membership changes take effect immediately.
141+
Token scoping controls what resources a token can access based on the `teams` claim in the JWT payload. The `normalize_token_teams()` function is the **single source of truth** for interpreting JWT team claims into a canonical form. For session tokens, `resolve_session_teams()` is the **single policy point** — it resolves teams from the database first (so revoked memberships take effect immediately), then narrows the result to the intersection with any JWT-embedded `teams` claim (so callers can scope a session to a subset of their memberships). If the intersection is empty (e.g. all JWT-claimed teams have been revoked), the full DB team list is returned as a fallback.
142142

143143
### Token Scoping Contract
144144

mcpgateway/auth.py

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,37 @@ def _resolve_teams_from_db_sync(email: str, is_admin: bool) -> Optional[List[str
257257
return team_ids
258258

259259

260+
def resolve_session_teams_sync(payload: Dict[str, Any], email: str, is_admin: bool) -> Optional[List[str]]:
261+
"""Synchronous companion to :func:`resolve_session_teams`.
262+
263+
Used by the StreamableHTTP transport which runs in a sync context.
264+
Applies the same DB-first + JWT intersection policy.
265+
266+
Args:
267+
payload: The decoded JWT payload dict.
268+
email: User email address (for the DB lookup).
269+
is_admin: Whether the user is an admin.
270+
271+
Returns:
272+
None (admin bypass), [] (public-only), or list of team ID strings.
273+
"""
274+
db_teams = _resolve_teams_from_db_sync(email, is_admin)
275+
276+
# Admin bypass — no narrowing possible
277+
if db_teams is None:
278+
return None
279+
280+
# Narrow to intersection when the JWT carries an explicit teams claim
281+
jwt_teams = payload.get("teams")
282+
if isinstance(jwt_teams, list) and jwt_teams:
283+
jwt_team_set = set(normalize_token_teams({"teams": jwt_teams}) or [])
284+
narrowed = [t for t in db_teams if t in jwt_team_set]
285+
if narrowed:
286+
return narrowed
287+
288+
return db_teams
289+
290+
260291
async def _resolve_teams_from_db(email: str, user_info) -> Optional[List[str]]:
261292
"""Resolve teams for session tokens from DB/cache.
262293
@@ -300,25 +331,67 @@ async def _resolve_teams_from_db(email: str, user_info) -> Optional[List[str]]:
300331
return team_ids
301332

302333

303-
async def resolve_session_teams(payload: Dict[str, Any], email: str, user_info) -> Optional[List[str]]: # pylint: disable=unused-argument
304-
"""Resolve teams for a session token from the database.
334+
_UNSET: Any = object() # sentinel distinguishing "not supplied" from explicit None
335+
336+
337+
async def resolve_session_teams(
338+
payload: Dict[str, Any],
339+
email: str,
340+
user_info,
341+
*,
342+
preresolved_db_teams: Optional[List[str]] = _UNSET,
343+
) -> Optional[List[str]]:
344+
"""Resolve teams for a session token, using DB as the authority.
345+
346+
The database is always consulted first so that revoked team memberships
347+
take effect immediately. If the JWT carries a ``teams`` claim, the
348+
result is narrowed to the **intersection** of the DB teams and the JWT
349+
teams — this lets callers scope a session to a subset of their actual
350+
memberships (e.g. single-team mode) without risking stale grants.
351+
352+
This is the **single policy point** for session-token team resolution.
353+
All code paths that need teams for a session token should call this
354+
function rather than inlining the decision.
305355
306-
Session tokens always resolve team membership from the DB/cache so that
307-
revoked memberships take effect immediately rather than persisting until
308-
token expiry. This is the **single policy point** for session-token team
309-
resolution — all code paths that need teams for a session token should call
310-
this function rather than inlining the decision.
356+
Policy:
357+
1. Resolve teams from DB/cache (``_resolve_teams_from_db``), or
358+
use *preresolved_db_teams* when the caller already fetched them
359+
(e.g. via a batched query).
360+
2. If DB returns ``None`` (admin bypass), return ``None``.
361+
3. If the JWT ``teams`` claim is a non-empty list, intersect with
362+
DB teams. If the intersection is non-empty, return it;
363+
otherwise fall back to the full DB result.
364+
4. Otherwise return the full DB result.
311365
312366
Args:
313-
payload: The decoded JWT payload dict (unused today, reserved for
314-
future policy checks).
367+
payload: The decoded JWT payload dict.
315368
email: User email address (for the DB lookup).
316369
user_info: User dict or EmailUser instance (for admin detection).
370+
preresolved_db_teams: If the caller already resolved DB teams (e.g.
371+
from a batched query), pass them here to skip the DB call.
372+
Pass ``None`` to indicate admin bypass was already determined.
317373
318374
Returns:
319375
None (admin bypass), [] (public-only), or list of team ID strings.
320376
"""
321-
return await _resolve_teams_from_db(email, user_info)
377+
if preresolved_db_teams is not _UNSET:
378+
db_teams: Optional[List[str]] = preresolved_db_teams
379+
else:
380+
db_teams = await _resolve_teams_from_db(email, user_info)
381+
382+
# Admin bypass — no narrowing possible
383+
if db_teams is None:
384+
return None
385+
386+
# Narrow to intersection when the JWT carries an explicit teams claim
387+
jwt_teams = payload.get("teams")
388+
if isinstance(jwt_teams, list) and jwt_teams:
389+
jwt_team_set = set(normalize_token_teams({"teams": jwt_teams}) or [])
390+
narrowed = [t for t in db_teams if t in jwt_team_set]
391+
if narrowed:
392+
return narrowed
393+
394+
return db_teams
322395

323396

324397
def normalize_token_teams(payload: Dict[str, Any]) -> Optional[List[str]]:
@@ -1183,13 +1256,11 @@ async def _set_auth_method_from_payload(payload: dict) -> None:
11831256
# Resolve teams based on token_use
11841257
token_use = payload.get("token_use")
11851258
if token_use == "session": # nosec B105 - Not a password; token_use is a JWT claim type
1186-
# Session token: use team_ids from batched query
1259+
# Session token: use team_ids from batched query via resolve_session_teams
11871260
user_dict = auth_ctx.get("user")
11881261
is_admin = user_dict.get("is_admin", False) if user_dict else False
1189-
if is_admin:
1190-
teams = None # Admin bypass
1191-
else:
1192-
teams = auth_ctx.get("team_ids", [])
1262+
batch_teams = None if is_admin else auth_ctx.get("team_ids", [])
1263+
teams = await resolve_session_teams(payload, email, {"is_admin": is_admin}, preresolved_db_teams=batch_teams)
11931264
else:
11941265
# API token or legacy: use embedded teams
11951266
teams = normalize_token_teams(payload)
@@ -1225,8 +1296,11 @@ async def _set_auth_method_from_payload(payload: dict) -> None:
12251296
)
12261297
# Also populate teams-list cache so cached-path requests
12271298
# don't need an extra DB query via _resolve_teams_from_db()
1228-
if token_use == "session" and teams is not None: # nosec B105
1229-
await auth_cache.set_user_teams(f"{email}:True", teams)
1299+
# Cache the raw DB teams (batch_teams), not the narrowed
1300+
# intersection (teams), so that other sessions for the same
1301+
# user see the full membership and can narrow independently.
1302+
if token_use == "session" and batch_teams is not None: # nosec B105
1303+
await auth_cache.set_user_teams(f"{email}:True", batch_teams)
12301304
except Exception as cache_set_error:
12311305
logger.debug(f"Failed to cache auth context: {cache_set_error}")
12321306

mcpgateway/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
# First-Party
6767
from mcpgateway import __version__
6868
from mcpgateway.admin import admin_router, set_logging_service
69-
from mcpgateway.auth import _check_token_revoked_sync, _lookup_api_token_sync, _resolve_teams_from_db, get_current_user, get_user_team_roles, normalize_token_teams
69+
from mcpgateway.auth import _check_token_revoked_sync, _lookup_api_token_sync, get_current_user, get_user_team_roles, normalize_token_teams, resolve_session_teams
7070
from mcpgateway.bootstrap_db import main as bootstrap_db
7171
from mcpgateway.cache import ResourceCache, SessionRegistry
7272
from mcpgateway.common.models import InitializeResult
@@ -1760,7 +1760,7 @@ async def dispatch(self, request: Request, call_next): # pylint: disable=too-ma
17601760
token_use = payload.get("token_use")
17611761
if token_use == "session": # nosec B105 - Not a password; token_use is a JWT claim type
17621762
is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False)
1763-
token_teams = await _resolve_teams_from_db(username, {"is_admin": is_admin})
1763+
token_teams = await resolve_session_teams(payload, username, {"is_admin": is_admin})
17641764
else:
17651765
# API token or legacy path: embedded teams claim semantics
17661766
token_teams = normalize_token_teams(payload)

mcpgateway/middleware/token_scoping.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,8 +1199,13 @@ async def __call__(self, request: Request, call_next):
11991199

12001200
db = next(get_db())
12011201
try:
1202-
# Check team membership with shared session
1203-
if not self._check_team_membership(payload, db=db):
1202+
# Check team membership — only for API/legacy tokens whose teams
1203+
# come from JWT claims and may be stale. Session tokens skip this
1204+
# because resolve_session_teams() already resolved membership from
1205+
# the DB; re-checking the raw JWT claim here would conflict with
1206+
# the intersection-fallback semantics (stale JWT teams would cause
1207+
# a 403 even though the user has valid DB teams).
1208+
if token_use != "session" and not self._check_team_membership(payload, db=db): # nosec B105 - Not a password; token_use is a JWT claim type
12041209
logger.warning("Token rejected: User no longer member of associated team(s)")
12051210
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Token is invalid: User is no longer a member of the associated team")
12061211

mcpgateway/transports/streamablehttp_transport.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,16 +1331,14 @@ def _normalize_jwt_payload(payload: dict[str, Any]) -> dict[str, Any]:
13311331

13321332
token_use = payload.get("token_use")
13331333
if token_use == "session": # nosec B105 - Not a password; token_use is a JWT claim type
1334-
# Session token: resolve teams from DB/cache
1335-
if is_admin:
1336-
final_teams = None # Admin bypass
1337-
elif email:
1334+
# Session token: resolve teams from DB/cache via single policy point
1335+
if email:
13381336
# First-Party
1339-
from mcpgateway.auth import _resolve_teams_from_db_sync # pylint: disable=import-outside-toplevel
1337+
from mcpgateway.auth import resolve_session_teams_sync # pylint: disable=import-outside-toplevel
13401338

1341-
final_teams = _resolve_teams_from_db_sync(email, is_admin=False)
1339+
final_teams = resolve_session_teams_sync(payload, email, is_admin)
13421340
else:
1343-
final_teams = [] # No email — public-only
1341+
final_teams = None if is_admin else [] # No email — admin bypass or public-only
13441342
else:
13451343
# API token or legacy: use embedded teams from JWT
13461344
# First-Party
@@ -2815,19 +2813,16 @@ async def _auth_jwt(self, *, token: str) -> bool:
28152813
# Resolve teams based on token_use claim
28162814
token_use = user_payload.get("token_use")
28172815
if token_use == "session": # nosec B105 - Not a password; token_use is a JWT claim type
2818-
# Session token: resolve teams from DB/cache
2816+
# Session token: resolve teams from DB/cache via single policy point
28192817
user_email_for_teams = user_payload.get("sub") or user_payload.get("email")
28202818
is_admin_flag = user_payload.get("is_admin", False) or user_payload.get("user", {}).get("is_admin", False)
2821-
if is_admin_flag:
2822-
final_teams = None # Admin bypass
2823-
elif user_email_for_teams:
2824-
# Resolve teams synchronously with L1 cache (StreamableHTTP uses sync context)
2819+
if user_email_for_teams:
28252820
# First-Party
2826-
from mcpgateway.auth import _resolve_teams_from_db_sync # pylint: disable=import-outside-toplevel
2821+
from mcpgateway.auth import resolve_session_teams_sync # pylint: disable=import-outside-toplevel
28272822

2828-
final_teams = _resolve_teams_from_db_sync(user_email_for_teams, is_admin=False)
2823+
final_teams = resolve_session_teams_sync(user_payload, user_email_for_teams, is_admin_flag)
28292824
else:
2830-
final_teams = [] # No email — public-only
2825+
final_teams = None if is_admin_flag else [] # No email — admin bypass or public-only
28312826
else:
28322827
# API token or legacy: use embedded teams from JWT
28332828
# First-Party

tests/unit/mcpgateway/middleware/test_token_scoping.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,85 @@ async def test_legacy_token_without_token_use_uses_embedded_teams(self, middlewa
700700
# Verify _resolve_teams_from_db was NOT called
701701
mock_resolve_teams.assert_not_called()
702702

703+
@pytest.mark.asyncio
704+
async def test_session_token_calls_resolve_session_teams(self, middleware, mock_request):
705+
"""Verify middleware calls the public resolve_session_teams policy point, not _resolve_teams_from_db directly."""
706+
mock_request.url.path = "/servers"
707+
mock_request.method = "GET"
708+
mock_request.headers = {"Authorization": "Bearer session_token"}
709+
710+
session_payload = {
711+
"sub": "user@example.com",
712+
"token_use": "session",
713+
"teams": ["team-1"],
714+
"scopes": {"permissions": ["*"]},
715+
}
716+
717+
with patch.object(middleware, "_extract_token_scopes", return_value=session_payload):
718+
with patch("mcpgateway.middleware.token_scoping.resolve_session_teams", new=AsyncMock(return_value=["team-1"])) as mock_resolve:
719+
with patch.object(middleware, "_check_resource_team_ownership", return_value=True):
720+
call_next = AsyncMock(return_value="success")
721+
722+
result = await middleware(mock_request, call_next)
723+
724+
assert result == "success"
725+
mock_resolve.assert_awaited_once_with(session_payload, "user@example.com", {"is_admin": False})
726+
727+
@pytest.mark.asyncio
728+
async def test_session_token_skips_membership_check_on_stale_jwt_teams(self, middleware, mock_request):
729+
"""Session tokens skip _check_team_membership so stale JWT teams don't cause a spurious 403."""
730+
mock_request.url.path = "/servers"
731+
mock_request.method = "GET"
732+
mock_request.headers = {"Authorization": "Bearer session_token"}
733+
734+
# JWT claims stale team "revoked-team"; DB only has "db-team"
735+
session_payload = {
736+
"sub": "user@example.com",
737+
"token_use": "session",
738+
"teams": ["revoked-team"],
739+
"scopes": {"permissions": ["*"]},
740+
}
741+
742+
with patch.object(middleware, "_extract_token_scopes", return_value=session_payload):
743+
# resolve_session_teams already handles intersection & fallback
744+
with patch("mcpgateway.auth._resolve_teams_from_db", return_value=["db-team"]) as mock_resolve:
745+
with patch.object(middleware, "_check_team_membership", return_value=False) as mock_membership:
746+
with patch.object(middleware, "_check_resource_team_ownership", return_value=True):
747+
call_next = AsyncMock(return_value="success")
748+
749+
result = await middleware(mock_request, call_next)
750+
751+
assert result == "success"
752+
call_next.assert_called_once()
753+
mock_resolve.assert_called_once()
754+
# Session tokens must NOT call _check_team_membership
755+
mock_membership.assert_not_called()
756+
757+
@pytest.mark.asyncio
758+
async def test_api_token_still_checks_membership(self, middleware, mock_request):
759+
"""API tokens must still go through _check_team_membership validation."""
760+
mock_request.url.path = "/servers"
761+
mock_request.method = "GET"
762+
mock_request.headers = {"Authorization": "Bearer api_token"}
763+
764+
api_payload = {
765+
"sub": "user@example.com",
766+
"token_use": "api",
767+
"teams": ["stale-team"],
768+
"scopes": {"permissions": ["*"]},
769+
}
770+
771+
with patch.object(middleware, "_extract_token_scopes", return_value=api_payload):
772+
with patch("mcpgateway.middleware.token_scoping.normalize_token_teams", return_value=["stale-team"]):
773+
with patch.object(middleware, "_check_team_membership", return_value=False) as mock_membership:
774+
call_next = AsyncMock(return_value="success")
775+
776+
result = await middleware(mock_request, call_next)
777+
778+
# Should be a 403 response, not "success"
779+
assert result != "success"
780+
mock_membership.assert_called_once()
781+
703782
def test_check_team_membership_missing_user_email_denies(self, middleware):
704783
"""Team-scoped tokens without a user email should be rejected."""
705784
payload = {"teams": ["team-1"]}

0 commit comments

Comments
 (0)