mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
commit
02cba40bb6
8 changed files with 97 additions and 16 deletions
|
|
@ -84,7 +84,7 @@
|
|||
"limit": 56
|
||||
},
|
||||
"reportPrivateUsage": {
|
||||
"limit": 1822
|
||||
"limit": 1810
|
||||
},
|
||||
"reportRedeclaration": {
|
||||
"limit": 8
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue