Skip to content

Commit 6ab105f

Browse files
committed
refactor(#3003): harden intersection policy, improve tests and docs
- Extract _narrow_by_jwt_teams() shared helper to prevent logic drift between async and sync paths - Change empty-intersection semantics to fail-closed (return [] instead of falling back to full DB teams) - Fix cache-poisoning regression test to use narrowing payload so it actually detects regressions - Add direct unit tests for _narrow_by_jwt_teams edge cases - Add cache TTL staleness comments in token scoping middleware - Document teams:[] "no restriction" semantics with code comments - Update multitenancy.md, rbac.md, and securing.md with session token intersection tables, cache isolation behavior, and staleness notes Closes #3003 Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent 70bd627 commit 6ab105f

File tree

7 files changed

+187
-52
lines changed

7 files changed

+187
-52
lines changed

docs/docs/architecture/multitenancy.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,18 @@ 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()` / `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.
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 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. If the intersection is empty (e.g. all JWT-claimed teams have been revoked), an empty list is returned, which demotes the session to public-only scope — the user can still access public resources and their own private resources, but loses access to all team-scoped resources. An explicit `teams: []` (empty list) in the JWT is treated as "no restriction requested" and returns the full DB membership — this intentionally differs from `normalize_token_teams` where `[] → public-only`.
323+
- `_narrow_by_jwt_teams()` — Shared intersection logic used by both `resolve_session_teams` and `resolve_session_teams_sync` to prevent logic drift.
323324
- API tokens and legacy tokens always use `normalize_token_teams()` directly.
324325

325326
### Secure-First Defaults
326327

327328
The token scoping system follows a **secure-first design principle**: when in doubt, access is restricted.
328329

330+
#### API Tokens and Legacy Tokens
331+
332+
These use `normalize_token_teams()` directly — the JWT `teams` claim is the sole authority:
333+
329334
| JWT `teams` State | `is_admin` | Result | Access Level |
330335
|-------------------|------------|--------|--------------|
331336
| Key MISSING | any | `[]` | PUBLIC-ONLY (secure default) |
@@ -334,8 +339,24 @@ The token scoping system follows a **secure-first design principle**: when in do
334339
| Key `[]` (empty) | any | `[]` | PUBLIC-ONLY |
335340
| Key `["t1", "t2"]` | any | `["t1", "t2"]` | TEAM-SCOPED |
336341

342+
#### Session Tokens
343+
344+
Session tokens use `resolve_session_teams()` — the **database** is the authority, and the JWT `teams` claim only narrows (never broadens) the result:
345+
346+
| JWT `teams` State | DB Teams | Result | Access Level |
347+
|-------------------|----------|--------|--------------|
348+
| Key MISSING | `["t1", "t2"]` | `["t1", "t2"]` | Full DB membership |
349+
| Key `null` | `["t1", "t2"]` | `["t1", "t2"]` | Full DB membership |
350+
| Key `[]` (empty) | `["t1", "t2"]` | `["t1", "t2"]` | Full DB membership (no restriction requested) |
351+
| Key `["t1"]` | `["t1", "t2"]` | `["t1"]` | Narrowed to intersection |
352+
| Key `["revoked"]` | `["t1", "t2"]` | `[]` | Empty intersection — public-only (fail-closed) |
353+
| any | `None` (admin) | `None` | ADMIN BYPASS (DB authority) |
354+
337355
!!! warning "Critical Security Behavior"
338-
A **missing** `teams` key always results in public-only access, even for admin users. Admin bypass requires **explicit** `teams: null` combined with `is_admin: true`.
356+
A **missing** `teams` key always results in public-only access for API/legacy tokens, even for admin users. Admin bypass requires **explicit** `teams: null` combined with `is_admin: true`.
357+
358+
!!! note "Session Token Membership Staleness"
359+
Session tokens skip the `_check_team_membership` re-validation in the token scoping middleware because `resolve_session_teams()` already resolved membership from the database. Membership staleness is bounded by the `auth_cache` TTL. The cache stores the full DB membership (not the per-session narrowed intersection) so that multiple sessions for the same user narrow independently.
339360

340361
### Multi-Team Token Behavior
341362

@@ -349,7 +370,7 @@ This means the first team in the token's teams array has special significance fo
349370

350371
### Return Value Semantics
351372

352-
The `normalize_token_teams()` function returns:
373+
The `normalize_token_teams()` and `resolve_session_teams()` functions return:
353374

354375
| Return Value | Meaning | Query Behavior |
355376
|--------------|---------|----------------|

docs/docs/manage/rbac.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,17 @@ 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 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.
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), an empty list is returned, demoting the session to public-only scope — the user can still access public resources and their own private resources, but loses access to all team-scoped resources. An explicit `teams: []` in a session JWT is treated as "no restriction requested" and returns the full DB membership.
142142

143143
### Token Scoping Contract
144144

145145
The `teams` claim in JWT tokens determines resource visibility. The system follows a **secure-first design**: when in doubt, access is denied.
146146

147+
**API tokens and legacy tokens** — JWT `teams` claim is the sole authority:
148+
147149
```
148150
┌─────────────────────────────────────────────────────────────────────────────┐
149-
Token Teams Claim Handling
151+
API / Legacy Token Teams Claim Handling │
150152
├─────────────────────────────────────────────────────────────────────────────┤
151153
│ JWT Claim State │ is_admin: true │ is_admin: false │
152154
├───────────────────────────┼───────────────────────┼─────────────────────────┤
@@ -158,6 +160,24 @@ The `teams` claim in JWT tokens determines resource visibility. The system follo
158160
└───────────────────────────┴───────────────────────┴─────────────────────────┘
159161
```
160162

163+
**Session tokens** — DB is the authority; JWT `teams` only narrows (never broadens):
164+
165+
```
166+
┌──────────────────────────────────────────────────────────────────────────────────┐
167+
│ Session Token Teams Resolution │
168+
├──────────────────────────────────────────────────────────────────────────────────┤
169+
│ JWT Claim State │ DB Teams │ Result │ Access Level │
170+
├────────────────────────┼────────────────────┼───────────────────┼────────────────┤
171+
│ Missing / null / [] │ ["t1", "t2"] │ ["t1", "t2"] │ Full DB scope │
172+
│ ["t1"] │ ["t1", "t2"] │ ["t1"] │ Narrowed │
173+
│ ["revoked"] │ ["t1", "t2"] │ [] │ Public-only │
174+
│ any │ None (admin) │ None │ Admin bypass │
175+
└────────────────────────┴────────────────────┴───────────────────┴────────────────┘
176+
```
177+
178+
!!! note "Session Token Staleness"
179+
Session tokens skip `_check_team_membership` re-validation in the token scoping middleware because `resolve_session_teams()` already resolved membership from the database. Membership staleness is bounded by the `auth_cache` TTL. The cache stores the full DB membership (not the per-session narrowed intersection) so that multiple sessions for the same user narrow independently.
180+
161181
!!! warning "Admin Bypass Requirements"
162182
Admin bypass (unrestricted access) requires **BOTH** conditions:
163183

@@ -194,7 +214,7 @@ Key points:
194214
- Verify JWT/API token.
195215
- Resolve `token_use`.
196216
2. **Normalize/resolve teams**
197-
- `token_use=session` → resolve from DB (`_resolve_teams_from_db()`).
217+
- `token_use=session` → resolve from DB and optionally narrow by JWT claim (`resolve_session_teams()`).
198218
- `token_use!=session` → normalize JWT `teams` (`normalize_token_teams()`).
199219
3. **Layer 1: Token scoping**
200220
- Filter accessible resources by `token_teams`.

docs/docs/manage/securing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ Tokens can be scoped to specific teams using the `teams` JWT claim:
235235
**Security Default**: Non-admin tokens without explicit team scope default to public-only access (principle of least privilege).
236236

237237
!!! note "Session Tokens vs API Tokens"
238-
For `token_use: "session"` (Admin UI login), teams are resolved server-side from DB/cache on each request.
238+
For `token_use: "session"` (Admin UI login), teams are resolved server-side from DB/cache on each request via `resolve_session_teams()`. If the JWT carries a non-empty `teams` claim, the result is narrowed to the intersection of DB teams and JWT teams, allowing callers to scope a session to a subset of their memberships.
239239
For `token_use: "api"` or legacy tokens, teams are interpreted from the JWT `teams` claim using `normalize_token_teams()`.
240240

241241
#### Server-Scoped Tokens

mcpgateway/auth.py

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

259259

260+
def _narrow_by_jwt_teams(payload: Dict[str, Any], db_teams: Optional[List[str]]) -> Optional[List[str]]:
261+
"""Apply JWT intersection policy to DB-resolved teams.
262+
263+
Shared implementation used by both :func:`resolve_session_teams` and
264+
:func:`resolve_session_teams_sync` to prevent logic drift.
265+
266+
If *db_teams* is ``None`` (admin bypass), returns ``None`` immediately.
267+
If the JWT ``teams`` claim is a non-empty list, returns the intersection
268+
of *db_teams* and the JWT teams. If the intersection is empty (e.g.
269+
all JWT-claimed teams have been revoked), returns ``[]`` so that
270+
downstream enforcement denies the request rather than silently
271+
broadening scope.
272+
273+
Args:
274+
payload: The decoded JWT payload dict.
275+
db_teams: Teams resolved from the database, or ``None`` for admin bypass.
276+
277+
Returns:
278+
None (admin bypass), [] (public-only / empty intersection), or list of team ID strings.
279+
"""
280+
if db_teams is None:
281+
return None
282+
283+
jwt_teams = payload.get("teams")
284+
if isinstance(jwt_teams, list) and jwt_teams:
285+
# Non-empty JWT teams → intersect with DB teams. An empty
286+
# intersection (all JWT teams revoked) returns [], which gives
287+
# public-only access and lets downstream enforcement deny the
288+
# request (fail-closed).
289+
jwt_team_set = set(normalize_token_teams({"teams": jwt_teams}) or [])
290+
return [t for t in db_teams if t in jwt_team_set]
291+
292+
# JWT teams absent, null, or empty list → no narrowing requested.
293+
# An explicit ``teams: []`` means "don't restrict by team" (i.e. use
294+
# the full DB membership), which intentionally differs from
295+
# ``normalize_token_teams`` where ``[] → public-only``. The
296+
# distinction exists because session tokens always start from DB-
297+
# resolved teams — an empty JWT claim simply means the caller did not
298+
# request a subset.
299+
return db_teams
300+
301+
260302
def resolve_session_teams_sync(payload: Dict[str, Any], email: str, is_admin: bool) -> Optional[List[str]]:
261303
"""Synchronous companion to :func:`resolve_session_teams`.
262304
@@ -272,20 +314,7 @@ def resolve_session_teams_sync(payload: Dict[str, Any], email: str, is_admin: bo
272314
None (admin bypass), [] (public-only), or list of team ID strings.
273315
"""
274316
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
317+
return _narrow_by_jwt_teams(payload, db_teams)
289318

290319

291320
async def _resolve_teams_from_db(email: str, user_info) -> Optional[List[str]]:
@@ -359,8 +388,9 @@ async def resolve_session_teams(
359388
(e.g. via a batched query).
360389
2. If DB returns ``None`` (admin bypass), return ``None``.
361390
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.
391+
DB teams. If the intersection is empty (all JWT-claimed teams
392+
revoked), return ``[]`` so downstream enforcement denies the
393+
request.
364394
4. Otherwise return the full DB result.
365395
366396
Args:
@@ -379,19 +409,7 @@ async def resolve_session_teams(
379409
else:
380410
db_teams = await _resolve_teams_from_db(email, user_info)
381411

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
412+
return _narrow_by_jwt_teams(payload, db_teams)
395413

396414

397415
def normalize_token_teams(payload: Dict[str, Any]) -> Optional[List[str]]:

mcpgateway/middleware/token_scoping.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,8 +1203,10 @@ async def __call__(self, request: Request, call_next):
12031203
# come from JWT claims and may be stale. Session tokens skip this
12041204
# because resolve_session_teams() already resolved membership from
12051205
# 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).
1206+
# the intersection semantics (stale JWT teams would cause a 403
1207+
# even though the user has valid DB teams).
1208+
# NOTE: session-token membership staleness is bounded by the
1209+
# auth_cache TTL (see _resolve_teams_from_db).
12081210
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
12091211
logger.warning("Token rejected: User no longer member of associated team(s)")
12101212
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Token is invalid: User is no longer a member of the associated team")
@@ -1220,8 +1222,11 @@ async def __call__(self, request: Request, call_next):
12201222
finally:
12211223
db.close()
12221224
else:
1223-
# Public-only token: no team membership check needed, but still check resource ownership
1224-
if not self._check_team_membership(payload):
1225+
# Public-only token (or session token with empty intersection):
1226+
# skip _check_team_membership for session tokens — the empty
1227+
# intersection already means no team-scoped access.
1228+
# Membership staleness bounded by auth_cache TTL.
1229+
if token_use != "session" and not self._check_team_membership(payload): # nosec B105 - Not a password; token_use is a JWT claim type
12251230
logger.warning("Token rejected: User no longer member of associated team(s)")
12261231
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Token is invalid: User is no longer a member of the associated team")
12271232

tests/unit/mcpgateway/middleware/test_token_scoping.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,12 +726,13 @@ async def test_session_token_calls_resolve_session_teams(self, middleware, mock_
726726

727727
@pytest.mark.asyncio
728728
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."""
729+
"""Session tokens skip _check_team_membership; stale JWT teams produce empty intersection (public-only)."""
730730
mock_request.url.path = "/servers"
731731
mock_request.method = "GET"
732732
mock_request.headers = {"Authorization": "Bearer session_token"}
733733

734734
# JWT claims stale team "revoked-team"; DB only has "db-team"
735+
# Intersection is empty → resolve_session_teams returns []
735736
session_payload = {
736737
"sub": "user@example.com",
737738
"token_use": "session",
@@ -740,14 +741,15 @@ async def test_session_token_skips_membership_check_on_stale_jwt_teams(self, mid
740741
}
741742

742743
with patch.object(middleware, "_extract_token_scopes", return_value=session_payload):
743-
# resolve_session_teams already handles intersection & fallback
744+
# resolve_session_teams returns [] (empty intersection)
744745
with patch("mcpgateway.auth._resolve_teams_from_db", return_value=["db-team"]) as mock_resolve:
745746
with patch.object(middleware, "_check_team_membership", return_value=False) as mock_membership:
746747
with patch.object(middleware, "_check_resource_team_ownership", return_value=True):
747748
call_next = AsyncMock(return_value="success")
748749

749750
result = await middleware(mock_request, call_next)
750751

752+
# Request proceeds with public-only scope (token_teams=[])
751753
assert result == "success"
752754
call_next.assert_called_once()
753755
mock_resolve.assert_called_once()

0 commit comments

Comments
 (0)