Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
931fa9a
feat(jwt): fall back to DB team memberships when JWT has no team claims
mateo-berri Jun 25, 2026
384ef86
style(jwt): use PEP 585/604 annotations in DB team fallback to clear …
mateo-berri Jun 25, 2026
c1f6695
fix(jwt): preserve DB teams on no-claim sync, model-gate DB fallback,…
mateo-berri Jun 26, 2026
9911998
fix(jwt): load team membership on DB fallback; scope header check to …
mateo-berri Jun 26, 2026
4a2b0c0
fix(jwt): surface DB-fallback membership lookup failures at warning l…
mateo-berri Jun 26, 2026
e897495
fix(jwt-auth): tighten db-team fallback gating and passthrough enforc…
cursoragent Jun 26, 2026
08194af
fix(jwt): don't upsert a provisional x-litellm-team-id before members…
mateo-berri Jun 26, 2026
3522123
fix(jwt): pin RBAC-asserted team against db-team-fallback header over…
mateo-berri Jun 26, 2026
1f038f8
fix(jwt): scope dual-claim membership sync to fallback_to_db_teams
mateo-berri Jun 26, 2026
7ff66f5
fix(jwt): drop user team IDs from db-fallback model-access 403 detail
mateo-berri Jun 26, 2026
ed21199
fix(jwt): keep db-team fallback off for alias-only tokens
cursoragent Jun 26, 2026
73022d3
test(jwt): cover alias-only token skipping db-team fallback
mateo-berri Jun 26, 2026
bed1a58
fix(jwt): prefer alias resolution over team_id_default
cursoragent Jun 26, 2026
d55aa14
fix(jwt): enforce team_allowed_routes in db-team fallback resolution
mateo-berri Jun 27, 2026
5d7d41b
fix(jwt): enforce team_allowed_routes on header-team db fallback path
cursoragent Jun 27, 2026
6bfce1c
Merge branch 'litellm_internal_staging' into litellm_jwt_db_team_fall…
mateo-berri Jul 6, 2026
9584918
refactor(jwt): narrow db-team fallback except clauses to actual failu…
mateo-berri Jul 6, 2026
12e7547
fix(jwt): collapse provisional header team lookup failure into member…
mateo-berri Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
261 changes: 247 additions & 14 deletions litellm/proxy/auth/handle_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -1196,10 +1196,22 @@ async def find_and_validate_specific_team_id(
) -> 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(
Expand All @@ -1222,8 +1234,6 @@ async def find_and_validate_specific_team_id(
)
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(
Expand Down Expand Up @@ -1329,7 +1339,10 @@ async def find_team_with_model_access(
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.",
Expand Down Expand Up @@ -1571,6 +1584,7 @@ def validate_object_id(
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.
Expand All @@ -1579,6 +1593,10 @@ def get_team_id_from_header(
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
Expand All @@ -1596,8 +1614,8 @@ def get_team_id_from_header(
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)}",
Expand Down Expand Up @@ -1694,11 +1712,20 @@ async def sync_user_role_and_teams(
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,
Expand Down Expand Up @@ -1818,6 +1845,148 @@ async def _resolve_single_team_fallback(
)
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
if not team_object:
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
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
verbose_proxy_logger.debug(
Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
"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."),
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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 _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
raise HTTPException(
status_code=403,
detail=(f"Team '{team_id}' (from x-litellm-team-id header) is not in your team memberships."),
)

@staticmethod
async def auth_builder(
api_key: str,
Expand Down Expand Up @@ -1911,24 +2080,44 @@ async def auth_builder(
## 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High: Empty team claims bypass team revocation

get_all_jwt_team_ids() returns [] both when the configured team claim is absent and when it is present but explicitly empty. With fallback_to_db_teams enabled, a user whose IdP now issues teams: [] can still fall back to DB memberships and keep using those teams' model access; sync_user_role_and_teams() also preserves DB teams in the same empty-list case. Please distinguish claim absence from an empty claim value, and only run/preserve the DB fallback when none of the configured team ID or alias claim fields are present.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the intended semantics of fallback_to_db_teams, and distinguishing an absent claim from an empty one the way suggested would break the feature's primary use case rather than close a bypass.

fallback_to_db_teams is opt-in and shifts the source of team truth from the JWT to LiteLLM's own membership table for deployments whose IdP does not carry team membership in the token. In that mode an absent team claim and an empty team claim both mean the same thing: the JWT does not assert teams, so the DB is authoritative. Many IdPs that omit team membership emit the configured claim as an empty array for a user with no groups rather than dropping the key entirely, so treating teams: [] as "claims present, therefore revoked, 403 under enforcement" would reject exactly the no-team-claim tokens this flag exists to support, including the Entra-style case that motivated it. That is why db_team_fallback keys off get_all_jwt_team_ids being empty and sync_user_role_and_teams preserves DB memberships in the no-claim case; preserving is required or the fallback that runs immediately after would have nothing to resolve.

Revocation in this mode is performed by removing the user's DB team membership, not by the IdP emitting an empty array; the JWT team claim is deliberately not the revocation channel once an operator opts into DB-backed teams. An operator who wants JWT-driven team revocation simply does not enable fallback_to_db_teams: with the flag off, the strict claim-based path and the upstream sync reconciliation are preserved exactly, so an empty or shrinking team claim strips DB memberships as before. The flag changes behavior only for tokens that carry no team claims, and only for deployments that opted in, which is the documented contract. Leaving the behavior as-is


Generated by Claude Code

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
# 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.
team_object = await get_team_object(
Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
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,
team_id_upsert=(jwt_handler.litellm_jwtauth.team_id_upsert and not db_team_fallback),
)
elif not team_id:
elif not team_id and not db_team_fallback:
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
## SPECIFIC TEAM ID
(
team_id,
Expand Down Expand Up @@ -2020,8 +2209,36 @@ async def auth_builder(
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,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
# 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,
Expand All @@ -2035,6 +2252,22 @@ async def auth_builder(
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,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
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(
Expand Down
Loading
Loading