fix(credentials): restore GET /credentials for Admin Viewer

PROXY_ADMIN_VIEW_ONLY passed the route gate but fell into the non-admin
branch and took the caller-administers-nothing 403, a regression against
the Admin Viewer read-parity rule this PR introduced. The viewer now gets
the full list like the proxy admin, with every value constant-masked so
the read-only role receives no usable secret
This commit is contained in:
Yucheng Zhu 2026-07-24 17:57:25 -07:00
parent a5834bf613
commit 3fe6b46f39
2 changed files with 45 additions and 1 deletions

View file

@ -307,7 +307,8 @@ async def get_credentials(
try:
is_proxy_admin = _is_proxy_admin(user_api_key_dict)
if is_proxy_admin:
is_admin_viewer = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
if is_proxy_admin or is_admin_viewer:
visible = list(litellm.credential_list)
else:
scope = await _caller_admin_scope(user_api_key_dict, prisma_client)

View file

@ -900,6 +900,49 @@ async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch):
assert generic["credential_values"]["otel_headers"] == raw_headers
@pytest.mark.asyncio
async def test_get_credentials_admin_viewer_gets_full_list_fully_masked(monkeypatch):
"""PROXY_ADMIN_VIEW_ONLY keeps read parity with PROXY_ADMIN on this endpoint:
the full credential list, including provider credentials, with every stored
value constant-masked so the read-only role receives no usable secret."""
raw_headers = "Authorization=Bearer collector-secret,x-api-key=api-secret"
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="openai",
credential_values={"api_key": "sk-secret"},
credential_info={"custom_llm_provider": "openai"},
),
CredentialItem(
credential_name="generic-otel",
credential_values={"otel_headers": raw_headers},
credential_info={
"credential_type": "logging",
"description": "generic",
},
),
],
)
response = await endpoints.get_credentials(
request=MagicMock(),
fastapi_response=MagicMock(),
user_api_key_dict=UserAPIKeyAuth(
api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
),
)
names = sorted(c["credential_name"] for c in response["credentials"])
assert names == ["generic-otel", "openai"]
assert all(
value == "********"
for c in response["credentials"]
for value in c["credential_values"].values()
)
assert "collector-secret" not in str(response)
assert "sk-secret" not in str(response)
@pytest.mark.asyncio
async def test_authorize_patch_malformed_stored_access_does_not_500(
_patch_team_admin_lookup,