mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
parent
13b518830c
commit
3a12f794b3
8 changed files with 208 additions and 5 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue