From 6829988c704111a4054435ecd80f37f83f3cfcb3 Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Wed, 29 Jul 2026 16:34:17 -0700 Subject: [PATCH] fix(credentials): scope the proxy-admin gate to trace destinations Credential create/update/delete and reads were gated to the proxy admin for every credential, which tightened provider-credential management for keys an admin had delegated /credentials to via allowed_routes; a backward-incompatible change beyond the OTEL trace-destination feature. Restrict the gate to trace destinations (credential_type="logging"). Those require the proxy admin regardless of allowed_routes, so a non-admin cannot create a global destination and receive other tenants' traces, nor convert a provider credential into one. Provider credentials keep their existing route-level authorization. Reads follow suit: the list hides destinations from non-admins and by_name returns 403 for a destination, while provider credentials read as before. --- .../proxy/credential_endpoints/endpoints.py | 69 +++- .../logging_exporter_access.py | 10 + .../credential_endpoints/test_endpoints.py | 342 +++++++++--------- .../test_logging_exporter_access.py | 13 + 4 files changed, 247 insertions(+), 187 deletions(-) diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 5c10aa1261f..1b1421ac5c6 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -14,6 +14,7 @@ from litellm.litellm_core_utils.litellm_logging import _get_masked_values from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +from litellm.proxy.management_endpoints.logging_exporter_access import is_logging_credential from litellm.proxy.management_endpoints.logging_exporter_validation import ( validate_credential_access, ) @@ -40,6 +41,22 @@ def _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool: return user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN +def _is_admin_tier(user_api_key_dict: UserAPIKeyAuth) -> bool: + return _is_proxy_admin(user_api_key_dict) or (user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + + +def _require_proxy_admin_for_logging(user_api_key_dict: UserAPIKeyAuth, *credential_infos: object) -> None: + """Require proxy admin only when a trace destination is involved. + + Trace destinations (``credential_type == "logging"``) are admin-managed regardless of a + key's ``allowed_routes``; provider credentials keep their existing route-level authorization. + Passing several infos (e.g. the stored credential and an update patch) gates the write when + any of them is a destination, which also blocks converting a provider credential into one. + """ + if any(is_logging_credential(info) for info in credential_infos): + _require_proxy_admin(user_api_key_dict) + + class CredentialHelperUtils: @staticmethod def encrypt_credential_values( @@ -77,7 +94,7 @@ async def create_credential( """ from litellm.proxy.proxy_server import llm_router, prisma_client - _require_proxy_admin(user_api_key_dict) + _require_proxy_admin_for_logging(user_api_key_dict, credential.credential_info) validate_credential_access(credential.credential_info) try: @@ -144,26 +161,24 @@ async def get_credentials( """ [BETA] endpoint. This might change unexpectedly. - Proxy-admin only (a proxy-admin-viewer may read). Credentials, including - admin-owned logging destinations, are managed exclusively by the proxy admin; - tenants never read them over the API. Secret values are masked for both - admin-tier readers, exactly as they were before this feature. + Lists credentials with secret values masked. Trace destinations + (``credential_type == "logging"``) are shown only to the proxy admin or a + proxy-admin-viewer; other callers see provider credentials only. Destinations + and their ``access`` scoping stay proxy-admin information. """ try: - is_proxy_admin = _is_proxy_admin(user_api_key_dict) - is_admin_viewer = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY - if not (is_proxy_admin or is_admin_viewer): - raise HTTPException( - status_code=403, - detail={"error": CommonProxyErrors.not_allowed_access.value}, - ) + visible_credentials = ( + litellm.credential_list + if _is_admin_tier(user_api_key_dict) + else [c for c in litellm.credential_list if not is_logging_credential(c.credential_info)] + ) masked_credentials = [ { "credential_name": credential.credential_name, "credential_values": _get_masked_values(credential.credential_values), "credential_info": credential.credential_info, } - for credential in litellm.credential_list + for credential in visible_credentials ] return {"success": True, "credentials": masked_credentials} except HTTPException: @@ -190,6 +205,11 @@ async def get_credential_by_name( try: for credential in litellm.credential_list: if credential.credential_name == credential_name: + if is_logging_credential(credential.credential_info) and not _is_admin_tier(user_api_key_dict): + raise HTTPException( + status_code=403, + detail={"error": CommonProxyErrors.not_allowed_access.value}, + ) masked_credential = CredentialItem( credential_name=credential.credential_name, credential_values=_get_masked_values( @@ -204,6 +224,8 @@ async def get_credential_by_name( status_code=404, detail="Credential not found. Got credential name: " + credential_name, ) + except HTTPException: + raise except Exception as e: verbose_proxy_logger.exception(e) raise handle_exception_on_proxy(e) @@ -267,19 +289,25 @@ async def delete_credential( """ from litellm.proxy.proxy_server import prisma_client - _require_proxy_admin(user_api_key_dict) - try: if prisma_client is None: raise HTTPException( status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - await CredentialsRepository(prisma_client).delete_by_name(credential_name) + credentials_repository = CredentialsRepository(prisma_client) + db_credential = await credentials_repository.find_by_name(credential_name) + _require_proxy_admin_for_logging( + user_api_key_dict, + db_credential.credential_info if db_credential is not None else None, + ) + await credentials_repository.delete_by_name(credential_name) ## DELETE FROM LITELLM ## litellm.credential_list = [cred for cred in litellm.credential_list if cred.credential_name != credential_name] return {"success": True, "message": "Credential deleted successfully"} + except HTTPException: + raise except Exception as e: return handle_exception_on_proxy(e) @@ -371,10 +399,10 @@ async def update_credential( """ [BETA] endpoint. This might change unexpectedly. - Proxy-admin only. Credentials, including admin-owned logging destinations and - their ``access`` scoping, are managed exclusively by the proxy admin. + Updating a trace destination (``credential_type == "logging"``), or converting a + credential into one, requires the proxy admin. Provider credentials keep their + existing route-level authorization. """ - _require_proxy_admin(user_api_key_dict) from litellm.proxy.proxy_server import prisma_client validate_credential_access(credential.credential_info) @@ -389,6 +417,7 @@ async def update_credential( db_credential = await credentials_repository.find_by_name(credential_name) if db_credential is None: raise HTTPException(status_code=404, detail="Credential not found in DB.") + _require_proxy_admin_for_logging(user_api_key_dict, db_credential.credential_info, credential.credential_info) merged_credential = update_db_credential(db_credential, _patch_to_credential_item(credential, credential_name)) credential_object_jsonified = jsonify_object(merged_credential.model_dump()) await credentials_repository.update_by_name( @@ -404,6 +433,8 @@ async def update_credential( patch=credential, ) return {"success": True, "message": "Credential updated successfully"} + except HTTPException: + raise except Exception as e: return handle_exception_on_proxy(e) diff --git a/litellm/proxy/management_endpoints/logging_exporter_access.py b/litellm/proxy/management_endpoints/logging_exporter_access.py index 93d65295be4..fffcff9f81b 100644 --- a/litellm/proxy/management_endpoints/logging_exporter_access.py +++ b/litellm/proxy/management_endpoints/logging_exporter_access.py @@ -32,6 +32,16 @@ def parse_credential_info(raw: object) -> CredentialInfo | None: return None +def is_logging_credential(raw: object) -> bool: + """Whether ``credential_info`` marks a trace destination (``credential_type == "logging"``). + + Trace destinations are proxy-admin-managed regardless of a key's ``allowed_routes``; + provider credentials are not logging and keep their existing route-level authorization. + """ + info = parse_credential_info(raw) + return info is not None and info.credential_type == "logging" + + def identity_scope(team_id: str | None, org_id: str | None) -> tuple[frozenset[str], frozenset[str]]: """A single request identity's scope as ``(team_ids, org_ids)`` for ``access_grants``.""" diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py index d89144dba26..580f0903bbe 100644 --- a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -1,11 +1,14 @@ -"""Admin-gating on credential mutations. +"""Authorization on credential endpoints, scoped to trace destinations. -Every ``/credentials`` operation -- GET, POST, PATCH, DELETE, for both logging -destinations and provider credentials -- is proxy-admin only (a proxy-admin-viewer -may read). Admin-owned OTEL logging destinations and their ``access`` scoping are -managed exclusively by the proxy admin; tenants never read or mutate them over the -API. Trace routing to identity-scoped destinations happens server-side in the -resolver, independent of this surface. +Trace destinations (``credential_type == "logging"``) are proxy-admin-managed +regardless of a key's ``allowed_routes``: create/update/delete require the proxy +admin, and reads of a destination are limited to the proxy admin (or a +proxy-admin-viewer). Provider credentials are not trace destinations, so the +handlers do not gate them by role; their authorization stays at the route layer +(a non-admin only reaches ``/credentials`` when an admin delegated it via +``allowed_routes``), exactly as it was before the destinations feature. Trace +routing to identity-scoped destinations happens server-side in the resolver, +independent of this surface. """ import os @@ -33,6 +36,23 @@ def _member(): _LOGGING_INFO = {"credential_type": "logging", "description": "langfuse_otel"} +_PROVIDER_INFO = {"custom_llm_provider": "openai"} + + +def _logging_cred(name="dest"): + return CredentialItem( + credential_name=name, + credential_values={"langfuse_host": "h"}, + credential_info=_LOGGING_INFO, + ) + + +def _provider_cred(name="openai-prod"): + return CredentialItem( + credential_name=name, + credential_values={"api_key": "sk-real"}, + credential_info=_PROVIDER_INFO, + ) @pytest.fixture @@ -43,16 +63,19 @@ def _connected_db(monkeypatch): monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key") monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) monkeypatch.setattr(proxy_server, "llm_router", None) + monkeypatch.setattr(litellm, "credential_list", []) repo = MagicMock() repo.create = AsyncMock() repo.delete_by_name = AsyncMock() + repo.update_by_name = AsyncMock() + repo.find_by_name = AsyncMock(return_value=None) monkeypatch.setattr(endpoints, "CredentialsRepository", lambda _client: repo) - monkeypatch.setattr( - endpoints.CredentialAccessor, "upsert_credentials", lambda creds: None - ) + monkeypatch.setattr(endpoints.CredentialAccessor, "upsert_credentials", lambda creds: None) return repo +# --- create ------------------------------------------------------------------ + @pytest.mark.asyncio async def test_create_logging_credential_forbidden_for_non_admin(_connected_db): with pytest.raises(HTTPException) as exc: @@ -87,16 +110,36 @@ async def test_create_logging_credential_allowed_for_admin(_connected_db): @pytest.mark.asyncio -async def test_create_provider_credential_forbidden_for_non_admin(_connected_db): - """POST is proxy-admin only for provider and logging credentials alike.""" +async def test_create_provider_credential_allowed_for_non_admin(_connected_db): + """Provider credentials are not trace destinations, so create is not gated by + role in the handler: a key that route-auth admitted (delegated via + ``allowed_routes``) keeps its pre-feature ability to create one.""" + result = await endpoints.create_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=CreateCredentialItem( + credential_name="openai", + credential_values={"api_key": "sk"}, + credential_info=_PROVIDER_INFO, + ), + user_api_key_dict=_member(), + ) + assert result["success"] is True + _connected_db.create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_provider_credential_cannot_smuggle_destination(_connected_db): + """A non-admin cannot create a destination by tagging a 'provider' create with + credential_type=logging: the gate keys off the payload's type, not its name.""" with pytest.raises(HTTPException) as exc: await endpoints.create_credential( request=MagicMock(), fastapi_response=MagicMock(), credential=CreateCredentialItem( - credential_name="openai", - credential_values={"api_key": "sk"}, - credential_info={"custom_llm_provider": "openai"}, + credential_name="looks-like-provider", + credential_values={"otel_endpoint": "https://attacker/v1/traces"}, + credential_info={"credential_type": "logging", "access": {"global": True}}, ), user_api_key_dict=_member(), ) @@ -104,53 +147,74 @@ async def test_create_provider_credential_forbidden_for_non_admin(_connected_db) _connected_db.create.assert_not_awaited() +# --- update ------------------------------------------------------------------ + @pytest.mark.asyncio async def test_update_logging_credential_forbidden_for_non_admin(_connected_db): + _connected_db.find_by_name = AsyncMock(return_value=_logging_cred()) with pytest.raises(HTTPException) as exc: await endpoints.update_credential( request=MagicMock(), fastapi_response=MagicMock(), - credential=CredentialItem( - credential_name="dest", - credential_values={}, - credential_info={"access": {"global": True}}, - ), + credential=UpdateCredentialItem(credential_info={"access": {"global": True}}), credential_name="dest", user_api_key_dict=_member(), ) assert exc.value.status_code == 403 + _connected_db.update_by_name.assert_not_awaited() @pytest.mark.asyncio -async def test_update_existing_logging_credential_forbidden_even_without_logging_patch( - _connected_db, monkeypatch -): - """A non-admin cannot edit a stored logging credential's values, even with a patch - that omits credential_info (the gate consults the in-memory credential too).""" - monkeypatch.setattr( - litellm, - "credential_list", - [ - CredentialItem( - credential_name="dest", - credential_values={"langfuse_host": "h"}, - credential_info=_LOGGING_INFO, - ) - ], - ) +async def test_update_existing_logging_credential_forbidden_even_without_logging_patch(_connected_db): + """A non-admin cannot edit a stored destination's values, even with a patch that + omits credential_info: the gate consults the stored (DB) credential too.""" + _connected_db.find_by_name = AsyncMock(return_value=_logging_cred()) with pytest.raises(HTTPException) as exc: await endpoints.update_credential( request=MagicMock(), fastapi_response=MagicMock(), - credential=CredentialItem( - credential_name="dest", - credential_values={"langfuse_host": "evil"}, - credential_info={}, - ), + credential=UpdateCredentialItem(credential_values={"langfuse_host": "evil"}), credential_name="dest", user_api_key_dict=_member(), ) assert exc.value.status_code == 403 + _connected_db.update_by_name.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_update_provider_credential_allowed_for_non_admin(_connected_db): + """A non-admin (delegated via allowed_routes) may still patch a provider + credential; only destinations are handler-gated.""" + _connected_db.find_by_name = AsyncMock(return_value=_provider_cred()) + result = await endpoints.update_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=UpdateCredentialItem(credential_values={"api_key": "sk-rotated"}), + credential_name="openai-prod", + user_api_key_dict=_member(), + ) + assert result["success"] is True + _connected_db.update_by_name.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_provider_to_destination_forbidden_for_non_admin(_connected_db): + """Converting a provider credential into a destination (patch adds a logging + credential_type / access) requires the proxy admin, so a non-admin can't + escalate a delegated provider credential into a global trace sink.""" + _connected_db.find_by_name = AsyncMock(return_value=_provider_cred()) + with pytest.raises(HTTPException) as exc: + await endpoints.update_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential=UpdateCredentialItem( + credential_info={"credential_type": "logging", "access": {"global": True}}, + ), + credential_name="openai-prod", + user_api_key_dict=_member(), + ) + assert exc.value.status_code == 403 + _connected_db.update_by_name.assert_not_awaited() def test_update_db_credential_preserves_existing_info_on_partial_patch(): @@ -222,131 +286,52 @@ def test_update_db_credential_preserves_untouched_access_subfields(): } +# --- delete ------------------------------------------------------------------ + @pytest.mark.asyncio -async def test_delete_logging_credential_forbidden_for_non_admin( - _connected_db, monkeypatch -): +async def test_delete_logging_credential_forbidden_for_non_admin(_connected_db): + _connected_db.find_by_name = AsyncMock(return_value=_logging_cred()) + with pytest.raises(HTTPException) as exc: + await endpoints.delete_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential_name="dest", + user_api_key_dict=_member(), + ) + assert exc.value.status_code == 403 + _connected_db.delete_by_name.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delete_provider_credential_allowed_for_non_admin(_connected_db): + """Deleting a provider credential is not handler-gated (route-auth governs it), + so a delegated non-admin keeps its pre-feature ability to delete one.""" + _connected_db.find_by_name = AsyncMock(return_value=_provider_cred()) + result = await endpoints.delete_credential( + request=MagicMock(), + fastapi_response=MagicMock(), + credential_name="openai-prod", + user_api_key_dict=_member(), + ) + assert result["success"] is True + _connected_db.delete_by_name.assert_awaited_once() + + +# --- reads ------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_credentials_hides_destinations_from_non_admin(monkeypatch): + """A non-admin list returns provider credentials (pre-feature behavior) but + never trace destinations, which stay proxy-admin information.""" monkeypatch.setattr( litellm, "credential_list", [ CredentialItem( - credential_name="dest", - credential_values={}, - credential_info=_LOGGING_INFO, - ) - ], - ) - with pytest.raises(HTTPException) as exc: - await endpoints.delete_credential( - request=MagicMock(), - fastapi_response=MagicMock(), - credential_name="dest", - user_api_key_dict=_member(), - ) - assert exc.value.status_code == 403 - _connected_db.delete_by_name.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_update_db_only_logging_credential_forbidden_for_non_admin( - _connected_db, monkeypatch -): - """A logging credential that exists ONLY in the DB (not resident in the - in-memory ``credential_list`` -- e.g. created on another scaled instance or - before a restart) must still gate a non-admin update. The gate falls back to - the DB so a credential_values-only patch can't redirect a logging - destination's endpoint without the proxy-admin check.""" - monkeypatch.setattr(litellm, "credential_list", []) # nothing in memory - _connected_db.find_by_name = AsyncMock( - return_value=CredentialItem( - credential_name="dest", - credential_values={"langfuse_host": "h"}, - credential_info=_LOGGING_INFO, - ) - ) - with pytest.raises(HTTPException) as exc: - await endpoints.update_credential( - request=MagicMock(), - fastapi_response=MagicMock(), - credential=CredentialItem( - credential_name="dest", - credential_values={"langfuse_host": "evil"}, - credential_info={}, + credential_name="openai", + credential_values={"api_key": "sk-secret"}, + credential_info=_PROVIDER_INFO, ), - credential_name="dest", - user_api_key_dict=_member(), - ) - assert exc.value.status_code == 403 - - -@pytest.mark.asyncio -async def test_delete_db_only_logging_credential_forbidden_for_non_admin( - _connected_db, monkeypatch -): - """Same DB-only fallback for delete: a non-admin can't delete a logging - credential that is resident only in the DB.""" - monkeypatch.setattr(litellm, "credential_list", []) - _connected_db.find_by_name = AsyncMock( - return_value=CredentialItem( - credential_name="dest", - credential_values={}, - credential_info=_LOGGING_INFO, - ) - ) - with pytest.raises(HTTPException) as exc: - await endpoints.delete_credential( - request=MagicMock(), - fastapi_response=MagicMock(), - credential_name="dest", - user_api_key_dict=_member(), - ) - assert exc.value.status_code == 403 - _connected_db.delete_by_name.assert_not_awaited() - - -# --- PATCH gating (proxy-admin only) ---------------------------------------- - -@pytest.mark.asyncio -async def test_provider_credential_patch_forbidden_for_non_admin( - _connected_db, monkeypatch -): - """A non-admin cannot PATCH a provider credential (or any credential): - without the gate a non-admin could rotate the upstream api_key.""" - provider_cred = CredentialItem( - credential_name="openai-prod", - credential_values={"api_key": "sk-real"}, - credential_info={"custom_llm_provider": "openai"}, - ) - monkeypatch.setattr(litellm, "credential_list", [provider_cred]) - _connected_db.find_by_name = AsyncMock(return_value=provider_cred) - _connected_db.update_by_name = AsyncMock() - - with pytest.raises(HTTPException) as exc: - await endpoints.update_credential( - request=MagicMock(), - fastapi_response=MagicMock(), - credential=CredentialItem( - credential_name="openai-prod", - credential_values={"api_key": "sk-stolen"}, - credential_info={}, - ), - credential_name="openai-prod", - user_api_key_dict=_member(), - ) - assert exc.value.status_code == 403 - _connected_db.update_by_name.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_get_credentials_forbidden_for_non_admin(monkeypatch): - """A non-proxy-admin (team-admin, org-admin, or plain internal_user) gets 403. - Credentials, including admin-owned logging destinations, are proxy-admin only; - the list is never exposed to a tenant over the API.""" - monkeypatch.setattr( - litellm, - "credential_list", - [ CredentialItem( credential_name="poc-langfuse", credential_values={"public_key": "pk-1"}, @@ -354,15 +339,42 @@ async def test_get_credentials_forbidden_for_non_admin(monkeypatch): ), ], ) + response = await endpoints.get_credentials( + request=MagicMock(), + fastapi_response=MagicMock(), + user_api_key_dict=_member(), + ) + names = [c["credential_name"] for c in response["credentials"]] + assert names == ["openai"] # destination hidden, provider visible + + +@pytest.mark.asyncio +async def test_get_credential_by_name_destination_forbidden_for_non_admin(monkeypatch): + monkeypatch.setattr(litellm, "credential_list", [_logging_cred("poc-langfuse")]) with pytest.raises(HTTPException) as exc: - await endpoints.get_credentials( + await endpoints.get_credential_by_name( request=MagicMock(), fastapi_response=MagicMock(), + credential_name="poc-langfuse", user_api_key_dict=_member(), ) assert exc.value.status_code == 403 +@pytest.mark.asyncio +async def test_get_credential_by_name_provider_allowed_for_non_admin(monkeypatch): + """A provider credential read by name is not handler-gated (masked as before).""" + monkeypatch.setattr(litellm, "credential_list", [_provider_cred("openai-prod")]) + result = await endpoints.get_credential_by_name( + request=MagicMock(), + fastapi_response=MagicMock(), + credential_name="openai-prod", + user_api_key_dict=_member(), + ) + assert result.credential_name == "openai-prod" + assert result.credential_values["api_key"] != "sk-real" # still masked + + def test_patch_credentials_route_targets_update_credential(): """Regression: the @router.patch decorator on /credentials/{name:path} must decorate update_credential, not one of the extracted helpers. A misplaced @@ -420,9 +432,7 @@ async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch): ) names = sorted(c["credential_name"] for c in response["credentials"]) assert names == ["generic-otel", "openai", "poc-langfuse"] - generic = next( - c for c in response["credentials"] if c["credential_name"] == "generic-otel" - ) + generic = next(c for c in response["credentials"] if c["credential_name"] == "generic-otel") # otel_headers carries the collector auth token, so the masker treats it as a # secret key: readable prefix only, never the full value. assert generic["credential_values"]["otel_headers"] != raw_headers @@ -457,9 +467,7 @@ async def test_get_credentials_admin_viewer_reads_same_masked_list_as_admin(monk viewer_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 - ), + user_api_key_dict=UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), ) admin_response = await endpoints.get_credentials( request=MagicMock(), @@ -471,5 +479,3 @@ async def test_get_credentials_admin_viewer_reads_same_masked_list_as_admin(monk assert names == ["generic-otel", "openai"] assert "collector-secret" not in str(viewer_response) assert "sk-secret-value" not in str(viewer_response) - - diff --git a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py index 71a276fe447..8cb61138efb 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py +++ b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_access.py @@ -15,6 +15,7 @@ from litellm.models.credentials import CredentialAccess, CredentialInfo, Credent from litellm.proxy.management_endpoints.logging_exporter_access import ( access_grants, identity_scope, + is_logging_credential, parse_credential_info, resolved_logging_exporter_names, ) @@ -192,3 +193,15 @@ def test_resolved_names_empty_scope_gets_global_only(monkeypatch): def test_resolved_names_empty_registry(monkeypatch): monkeypatch.setattr(litellm, "credential_list", []) assert resolved_logging_exporter_names("t1", "o1") == () + + +def test_is_logging_credential_distinguishes_destinations_from_provider_creds(): + # A trace destination is tagged credential_type=logging. + assert is_logging_credential({"credential_type": "logging", "access": {"global": True}}) is True + # Provider credentials are not destinations. + assert is_logging_credential({"custom_llm_provider": "openai"}) is False + # An access map without the logging type must not count (can't smuggle a destination). + assert is_logging_credential({"access": {"global": True}}) is False + # Absent / malformed info fails closed to "not a destination". + assert is_logging_credential(None) is False + assert is_logging_credential("not-a-dict") is False