fix(auth): match templated info routes by pattern

is_info_route compared the request path against info_routes with a bare `in`, so an entry
carrying a path parameter could never match: the incoming route holds a resolved id, not the
`{key_id}` template. /key/{key_id}/budgets was therefore refused for the view-only, team and
customer roles that /key/info serves, and its info_routes entry matched nothing at all.

Route it through check_route_access, the way is_management_route already does. It is the only
templated entry in the list today, so no other route changes reachability.
This commit is contained in:
ryan-crabbe-berri 2026-08-19 17:07:40 -07:00
parent 3b5ab06a95
commit dd720b6385
2 changed files with 49 additions and 1 deletions

View file

@ -440,8 +440,11 @@ class RouteChecks:
def is_info_route(route: str) -> bool:
"""
Check if route is an info route
Pattern-aware, like ``is_management_route``, so an info route carrying a path parameter is as
reachable as one without: the incoming route holds a resolved id, never the ``{...}`` template.
"""
return route in LiteLLMRoutes.info_routes.value
return RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.info_routes.value)
@staticmethod
def _is_azure_openai_route(route: str) -> bool:

View file

@ -149,3 +149,48 @@ def test_v2_user_info_route_access():
valid_token=valid_token,
request_data={},
)
@pytest.mark.parametrize(
"role",
[
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
LitellmUserRoles.TEAM,
LitellmUserRoles.CUSTOMER,
None,
],
)
def test_templated_info_route_is_as_reachable_as_a_plain_one(role):
"""
A resolved path parameter must not make an info route less reachable than one without.
`is_info_route` compared the request path against the route list by exact string, so
`/key/{key_id}/budgets` never matched a real request and callers who could read /key/info
were refused on the equivalent budgets route.
"""
user_obj = LiteLLM_UserTable(
user_id="test_user",
user_email="test@example.com",
user_role=role.value if role is not None else None,
max_budget=None,
spend=0.0,
models=[],
)
for route in ("/key/info", "/key/some-resolved-key-hash/budgets", "/key/budgets"):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=role,
route=route,
request=MagicMock(spec=Request),
valid_token=UserAPIKeyAuth(api_key="sk-test"),
request_data={},
)
def test_templated_route_matching_does_not_widen_unrelated_key_routes():
"""Pattern matching must stay scoped to the entries in the list, not every /key/<x>/<y> path."""
assert RouteChecks.is_info_route("/key/some-hash/regenerate") is False
assert RouteChecks.is_info_route("/key/some-hash/delete") is False
assert RouteChecks.is_info_route("/key/some-hash/budgets") is True