fix(proxy): require proxy admin role on GET /customer/info

The handler looked up any end_user_id in the global end-user table without
checking the caller's role, so an org admin whose route gate passed via
org_admin_allowed_routes could read every customer record in the deployment.
Gate it on PROXY_ADMIN / PROXY_ADMIN_VIEW_ONLY, matching /customer/list

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-09-03 08:43:21 +00:00
parent 658f50663d
commit b2eef615de
2 changed files with 58 additions and 4 deletions

View file

@ -503,9 +503,10 @@ async def new_end_user(
)
async def end_user_info(
end_user_id: str = fastapi.Query(description="End User ID in the request parameters"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> CustomerResponse:
"""
Get information about an end-user. An `end_user` is a customer (external user) of the proxy.
[Admin-only] Get information about an end-user. An `end_user` is a customer (external user) of the proxy.
Parameters:
- end_user_id (str, required): The unique identifier for the end-user
@ -519,6 +520,15 @@ async def end_user_info(
try:
from litellm.proxy.proxy_server import prisma_client
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
):
raise HTTPException(
status_code=401,
detail={"error": f"Admin-only endpoint. Your user role={user_api_key_dict.user_role}"},
)
if prisma_client is None:
raise HTTPException(
status_code=500,

View file

@ -177,6 +177,52 @@ def test_info_customer_not_found(mock_prisma_client, mock_user_api_key_auth):
assert response_json["error"]["code"] == "404"
@pytest.mark.parametrize(
"user_role",
[LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, LitellmUserRoles.TEAM],
)
def test_info_customer_non_admin_is_rejected(mock_prisma_client, user_role):
"""
Security regression: end users live in one global table with no tenant
scoping, so a non-admin caller (for example an org admin whose route gate
passes via `org_admin_allowed_routes`) must not be able to read another
tenant's customer record through /customer/info.
"""
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
return_value=LiteLLM_EndUserTable(user_id="victim-customer", alias="Victim", blocked=False)
)
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="org-admin-user", user_role=user_role)
try:
response = client.get(
"/customer/info?end_user_id=victim-customer",
headers={"Authorization": "Bearer org-admin-key"},
)
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 401
assert "Admin-only endpoint" in response.json()["error"]["message"]
mock_prisma_client.db.litellm_endusertable.find_first.assert_not_called()
def test_info_customer_admin_viewer_allowed(mock_prisma_client):
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
return_value=LiteLLM_EndUserTable(user_id="c1", alias="Customer One", blocked=False)
)
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
)
try:
response = client.get("/customer/info?end_user_id=c1", headers={"Authorization": "Bearer viewer-key"})
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
assert response.json()["user_id"] == "c1"
def test_delete_customer_not_found(mock_prisma_client, mock_user_api_key_auth):
"""
Test that delete_end_user raises a 404 ProxyException when user_ids do not exist.
@ -795,9 +841,7 @@ def test_char_new_body(mock_prisma_client, mock_user_api_key_auth):
@pytest.mark.parametrize("bad_duration", ["0s", "-5m"])
def test_customer_new_rejects_a_duration_that_never_advances(
mock_prisma_client, mock_user_api_key_auth, bad_duration
):
def test_customer_new_rejects_a_duration_that_never_advances(mock_prisma_client, mock_user_api_key_auth, bad_duration):
"""A zero-length window resets to "now", leaving the customer's budget row
permanently due for the reset job to re-read every tick."""
mock_prisma_client.db.litellm_endusertable.create = AsyncMock(return_value=_row(_FULL_DB_ROW))