feat(jwt): fall back to DB team memberships when JWT has no team claims (#31356)

* feat(jwt): fall back to DB team memberships when JWT has no team claims

* style(jwt): use PEP 585/604 annotations in DB team fallback to clear strict gate

* fix(jwt): preserve DB teams on no-claim sync, model-gate DB fallback, stop team-id leak

When fallback_to_db_teams is enabled and a JWT carries no team claims,
sync_user_role_and_teams previously computed teams_to_remove as every existing
DB membership and wiped the user out of all their teams on each request, which
also left the DB fallback nothing to resolve. Skip team removal in that case so
memberships survive and the fallback can attribute usage.

Apply the same per-team model-access check the claim-based path enforces when
selecting a DB fallback team, so a team's models restriction is no longer
bypassed; a team that cannot serve the requested model is skipped in favor of
one that can.

Drop the user's team-id list from the x-litellm-team-id membership 403 detail so
a valid-JWT caller can no longer enumerate team IDs.

* fix(jwt): load team membership on DB fallback; scope header check to provisional teams

The DB-team fallback resolved a team but never loaded its team membership
row, so per-team membership budget limits were silently skipped on that
path. _resolve_db_team_fallback now fetches the resolved team's membership
when a user_id is known and returns it, matching the claim-based path so
downstream LiteLLM_TeamMembership budget enforcement works there too.

The provisional x-litellm-team-id validation also fired on any non-None
team_id, including an RBAC role-derived one, which 403'd RBAC team flows
when the asserted team was not also a DB membership. It now runs only when
team_id actually came from the header (team_id == header_team_id).

* fix(jwt): surface DB-fallback membership lookup failures at warning level

A transient get_team_membership failure on the DB team fallback path is
recoverable: the team is still resolved and the request proceeds, just
without per-team membership budget enforcement for that request. Logging
that at debug hid a silent budget-enforcement gap from operators, so it now
logs at warning and states that enforcement was skipped. Behavior is
otherwise unchanged: the resolved team is returned with a None membership
rather than failing the request, covered by
test_resolve_db_team_fallback_survives_membership_lookup_error.

* fix(jwt-auth): tighten db-team fallback gating and passthrough enforcement

Resolves four issues in the fallback_to_db_teams path:

- _resolve_db_team_fallback now surfaces a model-access denial when memberships
  exist but none can access the requested model, instead of always returning
  the no-membership message
- auth_builder gates the fallback on real JWT team claims via
  get_all_jwt_team_ids so a configured team_id_default does not silently route
  claimless tokens to the default team
- A team selected only via _resolve_db_team_fallback is re-validated against
  the team's allowed_passthrough_routes; the earlier gate ran while team_id
  was still None
- sync_user_role_and_teams considers both plural and singular team claim
  shapes when reconciling DB memberships so singular-only tokens
  (Okta/Auth0 defaults) no longer leave stale teams behind

* fix(jwt): don't upsert a provisional x-litellm-team-id before membership check

When fallback_to_db_teams is on and the JWT carries no team claims, an
x-litellm-team-id header is accepted provisionally and only validated against
the user's DB memberships later in auth_builder. With team_id_upsert also
enabled, get_team_object ran the upsert on that unvalidated header team first,
so an attacker-supplied header could create an orphaned team row before the
403 membership check. Suppress the upsert whenever the team is provisional
(db_team_fallback), since a genuine membership team already exists and an
invalid one must not be created. Regression:
test_auth_builder_provisional_header_team_is_not_upserted.

* fix(jwt): pin RBAC-asserted team against db-team-fallback header override

When a JWT carries an RBAC team role but no group claims, auth_builder already
sets team_id from the RBAC object_id. db_team_fallback still evaluated true
there, so the provisional x-litellm-team-id path accepted a header team and
silently overrode the RBAC-asserted team with any team the caller belonged to.
Gate db_team_fallback on team_id being unset, and drive the header's provisional
acceptance off db_team_fallback rather than the raw flag, so an RBAC token plus
a non-claim header team is rejected with 403 instead of substituting the team.
Regression: test_auth_builder_header_cannot_override_rbac_team_under_db_fallback.

* fix(jwt): scope dual-claim membership sync to fallback_to_db_teams

The membership sync read both plural and singular JWT team claims via
get_all_jwt_team_ids unconditionally, which silently changed reconciliation
for every deployment using sync_user_role_and_teams, not just those opting
into fallback_to_db_teams: a singular-only IdP token that previously stripped
all DB teams would now be recognized. Gate the dual-claim read on
fallback_to_db_teams so flag-off deployments keep the upstream plural-only
behavior, honoring the PR's contract that existing deployments are unchanged.
Regression: test_sync_user_role_and_teams_singular_claim_only_recognized_under_flag.

* fix(jwt): drop user team IDs from db-fallback model-access 403 detail

The model-access-denied 403 in _resolve_db_team_fallback echoed the user's
full DB team-id list in its detail. It is only the caller's own memberships,
but it is inconsistent with the membership-validation 403 in the same feature
that was deliberately scrubbed of team IDs. Replace the enumerated list with a
generic "no team you are a member of has access" message. Regression extends
test_resolve_db_team_fallback_distinguishes_no_membership_vs_model_denied to
assert the team id is absent from the detail.

* fix(jwt): keep db-team fallback off for alias-only tokens

* test(jwt): cover alias-only token skipping db-team fallback

The autofix in ed21199 added a get_team_alias clause to the db_team_fallback
gate so an alias-only JWT (team_alias_jwt_field set, no team-id claims)
resolves its alias via find_and_validate_specific_team_id instead of being
mis-attributed to the user's first DB team, but it shipped without a
regression test. This drives auth_builder with an alias-only token whose
alias resolves to a different team than the user's DB membership and asserts
the result is the alias-resolved team; reverting the get_team_alias clause
flips the result to the DB-membership team, so the test fails without the fix

* fix(jwt): prefer alias resolution over team_id_default

When the JWT only carries an alias claim and the operator configures
team_id_default, JWTHandler.get_team_id silently substitutes the
default into find_and_validate_specific_team_id. That made the helper
return the default team without ever attempting alias resolution, so
spend and access attached to the default team even though the token
identified a different team via its alias. Use get_all_jwt_team_ids
(which ignores team_id_default) to detect when the resolved team_id is
only the default and clear it so alias resolution runs first; the
default remains the fallback when no alias claim is present.

* fix(jwt): enforce team_allowed_routes in db-team fallback resolution

The claim-based path runs allowed_routes_check when selecting a team, but
_resolve_db_team_fallback selected a team purely on model access, so a
DB-resolved team could reach routes excluded by team_allowed_routes with no
downstream backstop. This mirrors the claim path's route gate in the fallback,
exempting auth-enforced passthrough routes that are gated separately by
allowed_passthrough_routes at the call site

* fix(jwt): enforce team_allowed_routes on header-team db fallback path

The auto-pick DB-team fallback already gates against team_allowed_routes, but a claimless JWT presenting x-litellm-team-id under fallback_to_db_teams set team_id directly from the header and only re-validated DB membership afterwards, skipping the route gate. A caller could reach management/info routes that the JWT config narrowed for team-role callers by supplying the header even though the auto-pick path on the same route returns no team.

* refactor(jwt): narrow db-team fallback except clauses to actual failure types

* fix(jwt): collapse provisional header team lookup failure into membership denial

A caller holding a valid claimless JWT under fallback_to_db_teams could
distinguish nonexistent teams (404 from get_team_object) from existing
teams they do not belong to (membership 403) by varying x-litellm-team-id,
giving an authenticated team-id existence oracle. The provisional header
path now rewrites the lookup failure into the exact 403 the membership
check raises, while claim-backed header teams keep the upstream 404.

Also drop the unreachable falsy-team guard in _resolve_db_team_fallback
(get_team_object returns a team or raises, never None) and stop codecov
carryforward for three dead flags whose stale sessions were measured
against old file revisions and sank patch coverage with phantom
executable lines

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Mateo Wang 2026-07-06 17:17:10 -07:00 committed by GitHub
parent f4623a1325
commit 0855fa02b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1916 additions and 50 deletions

View file

@ -15,6 +15,16 @@ ignore:
flag_management:
default_rules:
carryforward: true
# Dead flags no CI job uploads anymore: their carried-forward sessions were
# measured against old revisions, and the stale line maps mark comment lines
# of since-edited files as missed, sinking patch coverage on unrelated PRs.
individual_flags:
- name: proxy-mgmt-behavior
carryforward: false
- name: security
carryforward: false
- name: proxy-db-schema-migration
carryforward: false
component_management:
individual_components:

View file

@ -4184,6 +4184,17 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
"authorization."
),
)
fallback_to_db_teams: bool = Field(
default=False,
description=(
"When True, users whose JWT contains no team claims are authenticated "
"using their database team memberships instead of receiving HTTP 403. "
"Usage is attributed to the user's first resolvable DB team, or to the "
"team specified via the x-litellm-team-id request header (validated "
"against DB membership). Requires user_id_upsert=True so that user "
"records exist before the fallback runs."
),
)
issuers: Optional[List[JWTIssuerConfig]] = Field(
default=None,
description="Optional issuer-bound JWT validation rules. When a token's `iss` matches a configured issuer, validation uses that issuer's JWKS, audience, and claim mappings. Tokens with an unlisted `iss` fall back to the global JWT_AUDIENCE/JWT_ISSUER validation path — this is additive routing, not an allow-list.",

View file

@ -12,7 +12,7 @@ import fnmatch
import hashlib
import os
import re
from typing import Any, List, Literal, Optional, Set, Tuple, Union, cast
from typing import Any, List, Literal, NoReturn, Optional, Set, Tuple, Union, cast
import jwt
from cryptography import x509
@ -1196,10 +1196,22 @@ class JWTAuthManager:
) -> Tuple[Optional[str], Optional[LiteLLM_TeamTable]]:
"""Find and validate specific team ID from team_id_jwt_field or team_alias_jwt_field"""
individual_team_id = jwt_handler.get_team_id(token=jwt_valid_token, default_value=None)
team_alias = jwt_handler.get_team_alias(token=jwt_valid_token, default_value=None)
# `get_team_id` silently substitutes `team_id_default` for a missing
# JWT team_id claim. When the token actually carries an alias claim,
# that substitution would mask the alias-resolved team, so prefer
# alias resolution. `get_all_jwt_team_ids` ignores `team_id_default`;
# an empty result means no real JWT team_id claim is present.
if (
team_alias
and individual_team_id is not None
and not jwt_handler.get_all_jwt_team_ids(token=jwt_valid_token)
):
individual_team_id = None
team_object: Optional[LiteLLM_TeamTable] = None
# First try to get team by team_id
if individual_team_id:
try:
team_object = await get_team_object(
@ -1222,8 +1234,6 @@ class JWTAuthManager:
)
return None, None
# If no team_id found, try to resolve via team_alias_jwt_field
team_alias = jwt_handler.get_team_alias(token=jwt_valid_token, default_value=None)
if team_alias:
verbose_proxy_logger.info(f"JWT Auth: Resolving team by alias: '{team_alias}'")
team_object = await get_team_object_by_alias(
@ -1329,7 +1339,10 @@ class JWTAuthManager:
denied_auth_enforced_pass_through_route = False
if not team_ids:
if jwt_handler.litellm_jwtauth.enforce_team_based_model_access:
if (
jwt_handler.litellm_jwtauth.enforce_team_based_model_access
and not jwt_handler.litellm_jwtauth.fallback_to_db_teams
):
raise HTTPException(
status_code=403,
detail="No teams found in token. `enforce_team_based_model_access` is set to True. Token must belong to a team.",
@ -1571,6 +1584,7 @@ class JWTAuthManager:
def get_team_id_from_header(
request_headers: Optional[dict],
allowed_team_ids: Set[str],
fallback_to_db_teams: bool = False,
) -> Optional[str]:
"""
Extract team_id from x-litellm-team-id header if present.
@ -1579,6 +1593,10 @@ class JWTAuthManager:
Args:
request_headers: Dictionary of request headers
allowed_team_ids: Set of team IDs the user is allowed to access (from JWT)
fallback_to_db_teams: When True and the JWT carries no team claims
(allowed_team_ids is empty), the header value is returned
provisionally and validated against DB memberships later in
auth_builder instead of being rejected here.
Returns:
The team_id from header if valid, None otherwise
@ -1596,8 +1614,8 @@ class JWTAuthManager:
if not header_team_id:
return None
# Validate that the team_id is in the allowed teams
if header_team_id not in allowed_team_ids:
defer_to_db_membership = fallback_to_db_teams and not allowed_team_ids
if not defer_to_db_membership and header_team_id not in allowed_team_ids:
raise HTTPException(
status_code=403,
detail=f"Team '{header_team_id}' from x-litellm-team-id header is not in your JWT's allowed teams. Allowed teams: {list(allowed_team_ids)}",
@ -1694,11 +1712,20 @@ class JWTAuthManager:
ttl=get_management_object_ttl(user_api_key_cache),
)
# Sync team memberships
jwt_team_ids = set(jwt_handler.get_team_ids_from_jwt(jwt_valid_token))
# Sync team memberships. With fallback_to_db_teams on, read both plural and
# singular claim shapes so a singular-only IdP token (e.g. Okta/Auth0) is
# not mistaken for claimless and left with stale DB memberships the fallback
# could later attribute. With the flag off, keep the upstream plural-only
# reconciliation so existing deployments are unchanged.
jwt_team_ids = set(
jwt_handler.get_all_jwt_team_ids(jwt_valid_token)
if jwt_handler.litellm_jwtauth.fallback_to_db_teams
else jwt_handler.get_team_ids_from_jwt(jwt_valid_token)
)
existing_teams = set(user_object.teams or [])
teams_to_add = jwt_team_ids - existing_teams
teams_to_remove = existing_teams - jwt_team_ids
preserve_db_teams_without_claims = jwt_handler.litellm_jwtauth.fallback_to_db_teams and not jwt_team_ids
teams_to_remove = set() if preserve_db_teams_without_claims else existing_teams - jwt_team_ids
if teams_to_add or teams_to_remove:
from litellm.proxy.management_endpoints.scim.scim_v2 import (
patch_team_membership,
@ -1818,6 +1845,155 @@ class JWTAuthManager:
)
return None, None, None
@staticmethod
async def _resolve_db_team_fallback(
user_object: LiteLLM_UserTable | None,
user_id: str | None,
requested_model: str | None,
route: str,
jwt_handler: JWTHandler,
enforce_team_based_model_access: bool,
team_id_upsert: bool,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None,
proxy_logging_obj: ProxyLogging,
request_method: str | None = None,
) -> tuple[str | None, LiteLLM_TeamTable | None, LiteLLM_TeamMembership | None]:
"""
Resolve a team for a user whose JWT carries no team claims by selecting
the first DB team membership that loads successfully and, when a model is
requested, can access that model mirroring the per-team model-access
check the claim-based path enforces, so a team's `models` restriction is
not bypassed by the fallback.
The same `team_allowed_routes` gate the claim-based path applies is
enforced here too, so a DB-selected team cannot reach a route the JWT
config excludes for team-role callers. Auth-enforced passthrough routes
are exempt from that gate by design (they are governed by the team's
`allowed_passthrough_routes`, re-checked by the caller).
The resolved team's membership row is loaded too (when user_id is set) so
per-team membership budget limits are enforced on the fallback path the
same as on the claim-based path.
Raises HTTP 403 when the user has no usable DB team membership and
`enforce_team_based_model_access` is set; otherwise returns (None, None, None).
"""
from litellm.proxy.proxy_server import llm_router
user_team_ids = user_object.teams if user_object else []
team_route_allowed = JWTAuthManager._is_team_route_allowed(
route=route, request_method=request_method, jwt_handler=jwt_handler
)
any_team_resolved = False
for candidate_team_id in user_team_ids:
try:
team_object = await get_team_object(
team_id=candidate_team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
team_id_upsert=team_id_upsert,
)
except HTTPException:
continue
any_team_resolved = True
if requested_model:
try:
await can_team_access_model(
model=requested_model,
team_object=team_object,
llm_router=llm_router,
team_model_aliases=None,
)
except ProxyException:
continue
if not team_route_allowed:
continue
verbose_proxy_logger.debug(
"JWT DB team fallback: resolved team_id=%s from user DB membership",
candidate_team_id,
)
if user_id:
return (
candidate_team_id,
team_object,
await get_team_membership(
user_id=user_id,
team_id=candidate_team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
),
)
return candidate_team_id, team_object, None
if enforce_team_based_model_access:
if requested_model and any_team_resolved:
raise HTTPException(
status_code=403,
detail=(
f"No team you are a member of has access to the requested "
f"model: {requested_model}. Check `/models` to see the models "
f"available to you."
),
)
raise HTTPException(
status_code=403,
detail=("User is not a member of any team. Add the user to a team via the LiteLLM UI or API."),
)
return None, None, None
@staticmethod
def _is_team_route_allowed(
route: str,
request_method: str | None,
jwt_handler: JWTHandler,
) -> bool:
"""
Whether a team-role caller may reach `route` per the JWT config's
`team_allowed_routes`. Auth-enforced passthrough routes are exempt
here; their team's `allowed_passthrough_routes` gate runs separately.
"""
normalized_method = request_method.upper() if isinstance(request_method, str) else None
return RouteChecks.is_auth_enforced_pass_through_route(
route=route, method=normalized_method
) or allowed_routes_check(
user_role=LitellmUserRoles.TEAM,
user_route=route,
litellm_proxy_roles=jwt_handler.litellm_jwtauth,
)
@staticmethod
def _raise_header_team_membership_denial(team_id: str) -> NoReturn:
"""
The single denial shape for a provisional x-litellm-team-id header,
raised identically for nonexistent teams and for teams the user is not
a member of, so the response does not reveal whether a team id exists.
"""
raise HTTPException(
status_code=403,
detail=(f"Team '{team_id}' (from x-litellm-team-id header) is not in your team memberships."),
)
@staticmethod
def _validate_header_team_in_db_membership(
team_id: str,
user_object: LiteLLM_UserTable | None,
) -> None:
"""
A provisional team_id from the x-litellm-team-id header (accepted without
JWT-team validation when the JWT carries no team claims) must exist in the
user's DB team memberships before it becomes request context.
"""
user_team_ids = user_object.teams if user_object else []
if team_id in user_team_ids:
return
JWTAuthManager._raise_header_team_membership_denial(team_id)
@staticmethod
async def auth_builder(
api_key: str,
@ -1911,24 +2087,49 @@ class JWTAuthManager:
## Check if team_id is specified via x-litellm-team-id header
all_team_ids = JWTAuthManager.get_all_team_ids(jwt_handler, jwt_valid_token)
specific_team_id = jwt_handler.get_team_id(token=jwt_valid_token, default_value=None)
if specific_team_id:
# The DB fallback only applies when the token carries no team identity at
# all. `get_all_jwt_team_ids` ignores `team_id_default` so a configured
# default does not hide a claimless token, `get_team_alias` covers
# alias-only tokens so the alias still resolves via
# `find_and_validate_specific_team_id`, and `team_id is None` excludes
# the RBAC team-role path (which already set `team_id`); otherwise a
# provisional x-litellm-team-id header could override an RBAC-asserted team.
db_team_fallback = (
jwt_handler.litellm_jwtauth.fallback_to_db_teams
and not jwt_handler.get_all_jwt_team_ids(token=jwt_valid_token)
and not jwt_handler.get_team_alias(token=jwt_valid_token, default_value=None)
and team_id is None
)
if specific_team_id and not db_team_fallback:
all_team_ids.add(specific_team_id)
header_team_id = JWTAuthManager.get_team_id_from_header(
request_headers=request_headers,
allowed_team_ids=all_team_ids,
fallback_to_db_teams=db_team_fallback,
)
if header_team_id:
team_id = header_team_id
team_object = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
team_id_upsert=jwt_handler.litellm_jwtauth.team_id_upsert,
)
elif not team_id:
# A provisional header team (accepted only because the JWT carries no
# team claims) is validated against DB membership further down; never
# upsert it here or an attacker-supplied x-litellm-team-id would create
# an orphaned team row before that check runs. A genuine membership team
# already exists, so suppressing the upsert in that case costs nothing.
try:
team_object = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
team_id_upsert=(jwt_handler.litellm_jwtauth.team_id_upsert and not db_team_fallback),
)
except HTTPException:
if not db_team_fallback:
raise
JWTAuthManager._raise_header_team_membership_denial(team_id)
elif not team_id and not db_team_fallback:
## SPECIFIC TEAM ID
(
team_id,
@ -2020,8 +2221,36 @@ class JWTAuthManager:
user_api_key_cache=user_api_key_cache,
)
# If JWT did not resolve team_id, attempt single-team DB fallback.
if team_id is None:
# If JWT did not resolve team_id, attempt a team fallback.
if team_id is None and db_team_fallback:
(
team_id,
team_object,
team_membership_object,
) = await JWTAuthManager._resolve_db_team_fallback(
user_object=user_object,
user_id=user_id,
requested_model=request_data.get("model"),
route=route,
jwt_handler=jwt_handler,
enforce_team_based_model_access=jwt_handler.litellm_jwtauth.enforce_team_based_model_access,
team_id_upsert=jwt_handler.litellm_jwtauth.team_id_upsert,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
request_method=request_method,
)
# The earlier passthrough gate ran when team_id was None; re-check
# against the DB-resolved team so a fallback-selected team must also
# pass the auth-enforced passthrough allowlist.
if team_id and not JWTAuthManager._team_has_passthrough_route_access(
team_object=team_object,
route=route,
request_method=request_method,
):
JWTAuthManager._raise_team_passthrough_route_denial(route=route)
elif team_id is None:
(
team_id,
team_object,
@ -2035,6 +2264,22 @@ class JWTAuthManager:
proxy_logging_obj=proxy_logging_obj,
team_id_upsert=jwt_handler.litellm_jwtauth.team_id_upsert,
)
elif db_team_fallback and team_id == header_team_id:
JWTAuthManager._validate_header_team_in_db_membership(
team_id=team_id,
user_object=user_object,
)
if not JWTAuthManager._is_team_route_allowed(
route=route,
request_method=request_method,
jwt_handler=jwt_handler,
):
raise HTTPException(
status_code=403,
detail=(
f"Team '{team_id}' (from x-litellm-team-id header) is not allowed to access route '{route}'."
),
)
## MAP USER TO TEAMS
await JWTAuthManager.map_user_to_teams(

File diff suppressed because it is too large Load diff