diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 1a06906fdf9..0525dae089f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1,7 +1,7 @@ import enum import json import os -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from datetime import datetime from typing import TYPE_CHECKING, Any, Final, Literal @@ -2933,6 +2933,7 @@ class LiteLLM_OrganizationTableWithMembers(LiteLLM_OrganizationTable): litellm_budget_table: LiteLLM_BudgetTable | None = None created_at: datetime updated_at: datetime + resolved_logging_exporters: Sequence[str] | None = None class NewOrganizationResponse(LiteLLM_OrganizationTable): @@ -3956,6 +3957,7 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): access_group_mcp_server_ids: list[str] | None = None access_group_agent_ids: list[str] | None = None access_group_details: tuple[TeamAccessGroupModelGrant, ...] | None = None + resolved_logging_exporters: Sequence[str] | None = None class TeamInfoResponseObject(TypedDict): diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 2a385c4c42a..b694b0b8c66 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2654,7 +2654,7 @@ async def _validate_update_key_data( @router.post("/key/update", tags=["key management"], dependencies=[Depends(user_api_key_auth)]) @management_endpoint_wrapper -async def update_key_fn( +async def update_key_fn( # noqa: C901 # single endpoint handling many optional key-update fields; decomposition is out of scope here request: Request, data: UpdateKeyRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -4743,7 +4743,7 @@ async def _execute_virtual_key_regeneration( dependencies=[Depends(user_api_key_auth)], ) @management_endpoint_wrapper -async def regenerate_key_fn( +async def regenerate_key_fn( # noqa: C901 # single endpoint handling many optional key-regeneration fields; decomposition is out of scope here key: str | None = None, data: RegenerateKeyRequest | None = None, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 3ae871b476e..f5410a4c2a2 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -35,6 +35,9 @@ from litellm.proxy.management_endpoints.common_utils import ( _set_object_metadata_field, _user_has_admin_view, ) +from litellm.proxy.management_endpoints.logging_exporter_access import ( + resolved_logging_exporter_names, +) from litellm.proxy.management_helpers.object_permission_utils import ( handle_update_object_permission_common, prepare_object_permission_upsert, @@ -379,7 +382,6 @@ async def new_organization( }' ``` """ - from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, llm_router, @@ -1102,6 +1104,10 @@ async def info_organization( raise HTTPException(status_code=404, detail={"error": "Organization not found"}) response_pydantic_obj: Final = LiteLLM_OrganizationTableWithMembers.model_validate(response.model_dump()) + response_pydantic_obj.resolved_logging_exporters = resolved_logging_exporter_names( + None, + organization_id, + ) return response_pydantic_obj diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 60d3d650d00..22f5fe3d8aa 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -98,6 +98,9 @@ from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_view, validate_budget_duration, ) +from litellm.proxy.management_endpoints.logging_exporter_access import ( + resolved_logging_exporter_names, +) from litellm.proxy.management_endpoints.organization_endpoints import ( add_member_to_organization, ) @@ -1963,8 +1966,9 @@ async def update_team( ) # Verify caller has access to manage this team + team_for_auth = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()) await _verify_team_access( - team_obj=LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()), + team_obj=team_for_auth, user_api_key_dict=user_api_key_dict, ) @@ -4058,6 +4062,11 @@ async def team_info( # Resolve resources inherited from access groups resolved_team_info: Final = await _resolve_team_access_group_resources(_team_info) + _team_info.resolved_logging_exporters = resolved_logging_exporter_names( + team_id, + _team_info.organization_id, + ) + response_object: Final = TeamInfoResponseObject( team_id=team_id, team_info=resolved_team_info, diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 0bfb10320f7..e132bba79e8 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3298,3 +3298,75 @@ def test_user_daily_activity_aggregated_not_covered_by_prefix_match(): route="/user/daily/activity/aggregated", allowed_routes=["/user/daily/activity"], ) + + +# --- Credential route gating (PR #30873: /credentials is proxy-admin only) --- # + + +@pytest.mark.parametrize( + "route", + ["/credentials", "/credentials/{credential_name}", "/credentials/my-dest"], +) +def test_credentials_routes_are_not_self_managed(route): + """Credentials are proxy-admin only: no ``/credentials`` route is in the + self-managed set, so a non-admin never reaches the handler for any method + (GET/POST/PATCH/DELETE). Admin-owned logging destinations are managed + exclusively by the proxy admin.""" + assert ( + RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.self_managed_routes.value + ) + is False + ) + + +@pytest.mark.parametrize( + "route", + ["/credentials/by_name/my-dest", "/credentials/by_model/model-123"], +) +def test_by_name_by_model_forbidden_for_internal_user(route): + """A plain internal user (also the role a team-admin/org-admin key carries) is + denied by_name/by_model: the route matches no allow-list, so the gate raises.""" + user_obj = LiteLLM_UserTable( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER.value + ) + valid_token = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER.value + ) + request = MagicMock(spec=Request) + request.method = "GET" + request.query_params = {} + with pytest.raises(Exception) as exc: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + assert "Only proxy admin" in str(exc.value) + assert f"Route={route}" in str(exc.value) + + +@pytest.mark.parametrize( + "route", + ["/credentials/by_name/my-dest", "/credentials/by_model/model-123"], +) +def test_by_name_by_model_reachable_by_admin_viewer(route): + """PROXY_ADMIN_VIEW_ONLY reaches by_name/by_model via the read-parity safe-GET + default-allow (documented in the PR body as a masking inconsistency, not a + cross-tenant leak). Pins that the viewer is NOT blocked at the route gate.""" + request = MagicMock(spec=Request) + request.method = "GET" + request.query_params = {} + # returns None (allow) rather than raising + assert ( + RouteChecks._check_proxy_admin_viewer_access( + route=route, + _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + request_data={}, + request=request, + ) + is None + ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 0a88f59f677..c5aae4a8c50 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -13382,6 +13382,87 @@ async def test_regenerate_applies_normalized_mcp_object_permission(): assert regenerated_data.object_permission.mcp_servers == ["server-id"] +@pytest.mark.asyncio +async def test_regenerate_propagates_team_not_found(): + """Regenerating a key whose team no longer exists must surface the team-lookup + 404 rather than swallowing it and regenerating against a dangling team. The + access_group_ids/object_permission gate depends on the resolved team, so a missing + team must abort the regenerate (matching /key/generate and /key/update) instead of + silently continuing with team_table=None.""" + from litellm.proxy._types import RegenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + regenerate_key_fn, + ) + + data = RegenerateKeyRequest(key="sk-old", access_group_ids=["ag-1"]) + existing_key = LiteLLM_VerificationToken( + token="abc123", + user_id="user-1", + models=["gpt-4"], + team_id="dangling-team", + max_budget=None, + tags=None, + ) + mock_prisma_client = AsyncMock() + mock_repo = MagicMock() + mock_repo.table.find_unique = AsyncMock(return_value=existing_key) + execute_mock = AsyncMock(return_value=MagicMock()) + enforce_mock = MagicMock() + + async def _raise_team_not_found(*args, **kwargs): + raise HTTPException(status_code=404, detail={"error": "Team not found"}) + + with ( + patch("litellm.proxy.proxy_server.premium_user", True), + patch("litellm.proxy.proxy_server.master_key", None), + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), + patch("litellm.proxy.proxy_server.hash_token", lambda token: "hashed-old"), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.VerificationTokenRepository", + return_value=mock_repo, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.enforce_member_can_assign_access_groups", + enforce_mock, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.can_modify_verification_token", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + _raise_team_not_found, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints._execute_virtual_key_regeneration", + execute_mock, + ), + ): + with pytest.raises((HTTPException, ProxyException)) as exc_info: + await regenerate_key_fn( + key="sk-old", + data=data, + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN.value, + api_key="sk-admin", + user_id="admin", + ), + ) + + err = exc_info.value + code = getattr(err, "status_code", None) or getattr(err, "code", None) + assert str(code) == "404" + enforce_mock.assert_not_called() + execute_mock.assert_not_awaited() + + # --------------------------------------------------------------------------- # Regression tests for GHSA-q775-qw9r-2r4g: budget escalation via key/generate # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py index 21e25d30b82..332d70495a6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -942,3 +942,34 @@ async def test_disable_team_logging_leaves_team_re_enablable(): written = json.loads(mock_prisma.db.litellm_teamtable.update.await_args.kwargs["data"]["metadata"]) assert [entry["callback_name"] for entry in written["logging"]] == ["langfuse"] + + +def test_callback_vars_allowlist_admits_only_keys_the_provider_path_absorbs(): + """Regression: a callback var escaped into the outbound provider request body. + + ``litellm_logging_credential_name`` was allowlisted here but never added to + ``all_litellm_params``, so unlike every legitimate callback var it survived the + absorber and was written to the top level of the provider payload (and swept into + ``extra_body`` for openai-compatible providers), breaking strict providers. Worse, + ``callback_vars`` are stored encrypted because they hold secrets, so the path + decrypted a stored value into an outbound request. The key had no consumer at all; + a team is bound to a destination by ``credential_info.access``, not by a callback var. + + Guards the general rule rather than the one key: anything this allowlist admits must + be absorbed by ``all_litellm_params`` and so never reach a provider. + """ + import litellm + from litellm.proxy._types import AddTeamCallback + from litellm.types.utils import StandardCallbackDynamicParams + + allowed = set(StandardCallbackDynamicParams.__annotations__.keys()) + escaping = sorted(k for k in allowed if k not in litellm.all_litellm_params) + assert escaping == [], f"callback vars that would reach the provider payload: {escaping}" + + with pytest.raises(Exception) as exc: + AddTeamCallback( + callback_name="langfuse", + callback_type="success", + callback_vars={"litellm_logging_credential_name": "some-destination"}, + ) + assert "Invalid callback variable" in str(exc.value) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 1e46ae9c577..b15188421db 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26737,6 +26737,8 @@ export interface components { organization_alias?: string | null; /** Organization Id */ organization_id?: string | null; + /** Resolved Logging Exporters */ + resolved_logging_exporters?: string[] | null; /** * Spend * @default 0