Merge pull request #37756 from BerriAI/litellm_jwt_team_allowed_routes_wildcard

fix(auth): support wildcard prefixes in jwt team_allowed_routes
This commit is contained in:
ryan-crabbe-berri 2026-08-25 13:49:31 -07:00 committed by GitHub
commit 02cba40bb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 97 additions and 16 deletions

View file

@ -84,7 +84,7 @@
"limit": 56
},
"reportPrivateUsage": {
"limit": 1822
"limit": 1810
},
"reportRedeclaration": {
"limit": 8

View file

@ -1130,7 +1130,8 @@ def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool:
Parameters:
- user_route: str - the route the user is trying to call
- allowed_routes: List[str|LiteLLMRoutes] - the list of allowed routes for the user.
- allowed_routes: List[str|LiteLLMRoutes] - the list of allowed routes for the user. Entries are a route group name
(e.g. "openai_routes"), an exact route, or a trailing-wildcard prefix (e.g. "/internal-models/*").
"""
from starlette.routing import compile_path
@ -1140,7 +1141,7 @@ def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool:
regex, _, _ = compile_path(template)
if regex.match(user_route):
return True
elif allowed_route == user_route:
elif RouteChecks.route_matches_wildcard_pattern(route=user_route, pattern=allowed_route):
return True
return False

View file

@ -608,7 +608,7 @@ def route_in_additonal_public_routes(current_route: str):
# Check wildcard patterns
for route_pattern in routes_defined:
if RouteChecks._route_matches_wildcard_pattern(route=current_route, pattern=route_pattern):
if RouteChecks.route_matches_wildcard_pattern(route=current_route, pattern=route_pattern):
return True
return False

View file

@ -181,7 +181,7 @@ class RouteChecks:
# check if wildcard pattern is allowed
for allowed_route in valid_token.allowed_routes:
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route):
if RouteChecks.route_matches_wildcard_pattern(route=route, pattern=allowed_route):
return True
if denied_auth_enforced_pass_through_route:
@ -329,7 +329,7 @@ class RouteChecks:
route_allowed = True
break
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route):
if RouteChecks.route_matches_wildcard_pattern(route=route, pattern=allowed_route):
route_allowed = True
break
@ -397,7 +397,7 @@ class RouteChecks:
return True
# Check for wildcard patterns like "/containers/*"
if RouteChecks._is_wildcard_pattern(pattern=openai_route):
if RouteChecks._route_matches_wildcard_pattern(route=route, pattern=openai_route):
if RouteChecks.route_matches_wildcard_pattern(route=route, pattern=openai_route):
return True
# Check for Google routes with placeholders like "/v1beta/models/{model_name}:generateContent"
@ -517,7 +517,7 @@ class RouteChecks:
return pattern.endswith("*")
@staticmethod
def _route_matches_wildcard_pattern(route: str, pattern: str) -> bool:
def route_matches_wildcard_pattern(route: str, pattern: str) -> bool:
"""
Check if route matches the wildcard pattern
@ -594,7 +594,7 @@ class RouteChecks:
# e.g calling /anthropic/v1/messages is allowed if allowed_routes has /anthropic/*
#########################################################
if any(
RouteChecks._route_matches_wildcard_pattern(route=route, pattern=allowed_route)
RouteChecks.route_matches_wildcard_pattern(route=route, pattern=allowed_route)
for allowed_route in allowed_routes
if RouteChecks._is_wildcard_pattern(pattern=allowed_route)
):

View file

@ -30,7 +30,7 @@ class PolicyMatcher:
"""
Check if a value matches any of the given patterns.
Uses the existing RouteChecks._route_matches_wildcard_pattern helper.
Uses the existing RouteChecks.route_matches_wildcard_pattern helper.
Args:
value: The value to check (e.g., team alias, key alias, model)
@ -45,7 +45,7 @@ class PolicyMatcher:
for pattern in patterns:
# Use existing wildcard pattern matching helper
if RouteChecks._route_matches_wildcard_pattern(route=value, pattern=pattern):
if RouteChecks.route_matches_wildcard_pattern(route=value, pattern=pattern):
return True
return False

View file

@ -100,7 +100,7 @@ def _filter_keys_by_tags(keys: list, tag_patterns: list) -> tuple:
key_alias = key.key_alias or ""
key_tags = _get_tags_from_metadata(key.metadata, getattr(key, "metadata_json", None))
if key_tags and any(
RouteChecks._route_matches_wildcard_pattern(route=tag, pattern=pat)
RouteChecks.route_matches_wildcard_pattern(route=tag, pattern=pat)
for tag in key_tags
for pat in tag_patterns
):
@ -123,7 +123,7 @@ def _filter_teams_by_tags(teams: list, tag_patterns: list) -> tuple:
team_alias = team.team_alias or ""
team_tags = _get_tags_from_metadata(team.metadata)
if team_tags and any(
RouteChecks._route_matches_wildcard_pattern(route=tag, pattern=pat)
RouteChecks.route_matches_wildcard_pattern(route=tag, pattern=pat)
for tag in team_tags
for pat in tag_patterns
):
@ -152,7 +152,7 @@ async def _find_affected_by_team_patterns(
for team in all_teams:
team_alias = team.team_alias or ""
if team_alias and any(
RouteChecks._route_matches_wildcard_pattern(route=team_alias, pattern=pat) for pat in team_patterns
RouteChecks.route_matches_wildcard_pattern(route=team_alias, pattern=pat) for pat in team_patterns
):
if team_alias not in existing_teams:
new_teams.append(team_alias)
@ -190,7 +190,7 @@ async def _find_affected_keys_by_alias(prisma_client: object, key_patterns: list
for key in keys:
key_alias = key.key_alias or ""
if key_alias and any(
RouteChecks._route_matches_wildcard_pattern(route=key_alias, pattern=pat) for pat in key_patterns
RouteChecks.route_matches_wildcard_pattern(route=key_alias, pattern=pat) for pat in key_patterns
):
if key_alias not in existing_keys:
affected.append(key_alias)

View file

@ -7012,6 +7012,86 @@ def test_model_has_no_cost_mapping_alias_to_a_group_priced_through_model_info_is
assert model_has_no_cost_mapping(model="model-info-priced-alias", llm_router=router) is False
@pytest.mark.parametrize(
"user_route, expected",
[
("/internal-models/v1/chat/completions", True),
("/internal-models/newly-registered-model/predict", True),
("/internal-models-other/v1/chat/completions", False),
("/anthropic/v1/messages", False),
],
)
def test_team_allowed_routes_wildcard_prefix_matches_unregistered_passthrough_routes(user_route, expected):
"""A `/prefix/*` entry in `team_allowed_routes` must cover every route under that prefix, so
passthrough endpoints registered after the proxy config was written are reachable without an
exact-route config change."""
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.auth_checks import allowed_routes_check
assert (
allowed_routes_check(
user_role=LitellmUserRoles.TEAM,
user_route=user_route,
litellm_proxy_roles=LiteLLM_JWTAuth(team_allowed_routes=["/internal-models/*"]),
)
is expected
)
def test_team_allowed_routes_exact_route_does_not_become_a_prefix_grant():
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.auth_checks import allowed_routes_check
roles = LiteLLM_JWTAuth(team_allowed_routes=["/internal-models/model-a"])
assert (
allowed_routes_check(user_role=LitellmUserRoles.TEAM, user_route="/internal-models/model-a", litellm_proxy_roles=roles)
is True
)
assert (
allowed_routes_check(user_role=LitellmUserRoles.TEAM, user_route="/internal-models/model-b", litellm_proxy_roles=roles)
is False
)
def test_admin_allowed_routes_wildcard_prefix_is_honored():
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.auth_checks import allowed_routes_check
roles = LiteLLM_JWTAuth(admin_allowed_routes=["/internal-models/*"])
assert (
allowed_routes_check(
user_role=LitellmUserRoles.PROXY_ADMIN, user_route="/internal-models/anything", litellm_proxy_roles=roles
)
is True
)
assert (
allowed_routes_check(
user_role=LitellmUserRoles.PROXY_ADMIN, user_route="/other/anything", litellm_proxy_roles=roles
)
is False
)
def test_team_allowed_routes_named_route_group_still_resolves():
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.auth_checks import allowed_routes_check
roles = LiteLLM_JWTAuth(team_allowed_routes=["openai_routes"])
assert (
allowed_routes_check(
user_role=LitellmUserRoles.TEAM, user_route="/v1/chat/completions", litellm_proxy_roles=roles
)
is True
)
assert (
allowed_routes_check(user_role=LitellmUserRoles.TEAM, user_route="/key/generate", litellm_proxy_roles=roles)
is False
)
@pytest.mark.asyncio
async def test_invalidate_team_member_spend_state_sets_the_spend_counter_and_clears_both_membership_cache_keys():
"""A team-member budget reset (new_spend passed) must SET the spend counter to the reset

View file

@ -1,4 +1,4 @@
// Mirrors request-time matching (RouteChecks._route_matches_wildcard_pattern): only a
// Mirrors request-time matching (RouteChecks.route_matches_wildcard_pattern): only a
// trailing "*" is a wildcard (prefix match). Anything else - including a "?" or a
// non-trailing "*" - is compared by exact equality when a request is matched, so it is
// treated as a concrete alias that must exist.