feat(proxy): disclose resolved destinations on team and organization info

Adds resolved_logging_exporters to /team/info and /organization/info, naming every destination whose access scope grants that identity and which actually builds.

Squashed onto the rebased export branch; the previous history conflicted with staging's Final-annotation pass.
This commit is contained in:
Yucheng Zhu 2026-08-07 19:43:24 -07:00
parent 13b518830c
commit 3a12f794b3
8 changed files with 208 additions and 5 deletions

View file

@ -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):

View file

@ -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),

View file

@ -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

View file

@ -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,

View file

@ -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
)

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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)

View file

@ -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