fix(credentials): mask all credential values for lower roles on GET /credentials

A team-admin or org-admin listing destinations picks them by name and never
needs the stored values. The keyed masking heuristic misses fields like
otel_headers, exactly where the generic OTLP adapter keeps its auth header,
so the non-admin branch now masks every value with a constant instead of
relying on key-name matching. Proxy-admin responses are unchanged
This commit is contained in:
Yucheng Zhu 2026-07-24 17:54:25 -07:00
parent f08cf3aa81
commit a5834bf613
2 changed files with 61 additions and 3 deletions

View file

@ -306,7 +306,8 @@ async def get_credentials(
from litellm.proxy.proxy_server import prisma_client
try:
if _is_proxy_admin(user_api_key_dict):
is_proxy_admin = _is_proxy_admin(user_api_key_dict)
if is_proxy_admin:
visible = list(litellm.credential_list)
else:
scope = await _caller_admin_scope(user_api_key_dict, prisma_client)
@ -331,7 +332,11 @@ async def get_credentials(
masked_credentials = [
{
"credential_name": credential.credential_name,
"credential_values": _get_masked_values(credential.credential_values),
"credential_values": (
_get_masked_values(credential.credential_values)
if is_proxy_admin
else dict.fromkeys(credential.credential_values, "********")
),
"credential_info": credential.credential_info,
}
for credential in visible

View file

@ -617,6 +617,46 @@ async def test_get_credentials_shows_only_in_scope_destinations_for_non_admin(
assert names == ["poc-langfuse"]
@pytest.mark.asyncio
async def test_get_credentials_masks_all_values_for_non_admin(
monkeypatch, _patch_team_admin_lookup
):
raw_headers = "Authorization=Bearer collector-secret,x-api-key=api-secret"
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="generic-otel",
credential_values={
"otel_endpoint": "https://collector.example.com/v1/traces",
"otel_headers": raw_headers,
},
credential_info={
"credential_type": "logging",
"description": "generic",
"access": {"teams": ["team-existing"]},
},
),
],
)
_patch_team_admin_lookup["ids"] = frozenset({"team-existing"})
response = await endpoints.get_credentials(
request=MagicMock(),
fastapi_response=MagicMock(),
user_api_key_dict=_team_admin_of(["team-existing"]),
)
values = response["credentials"][0]["credential_values"]
assert values == {
"otel_endpoint": "********",
"otel_headers": "********",
}
assert "collector-secret" not in str(response)
assert "api-secret" not in str(response)
@pytest.mark.asyncio
async def test_get_credentials_hides_out_of_scope_destination(
monkeypatch, _patch_team_admin_lookup
@ -822,6 +862,7 @@ async def test_patch_credentials_echoes_foreign_team_id_to_legit_team_admin(
@pytest.mark.asyncio
async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch):
raw_headers = "Authorization=Bearer collector-secret,x-api-key=api-secret"
monkeypatch.setattr(
litellm,
"credential_list",
@ -836,6 +877,14 @@ async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch):
credential_values={"public_key": "pk-1"},
credential_info=_DEST_WITH_TEAMS,
),
CredentialItem(
credential_name="generic-otel",
credential_values={"otel_headers": raw_headers},
credential_info={
"credential_type": "logging",
"description": "generic",
},
),
],
)
response = await endpoints.get_credentials(
@ -844,7 +893,11 @@ async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch):
user_api_key_dict=_admin(),
)
names = sorted(c["credential_name"] for c in response["credentials"])
assert names == ["openai", "poc-langfuse"]
assert names == ["generic-otel", "openai", "poc-langfuse"]
generic = next(
c for c in response["credentials"] if c["credential_name"] == "generic-otel"
)
assert generic["credential_values"]["otel_headers"] == raw_headers
@pytest.mark.asyncio