diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260702120000_add_logging_exporters_columns/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260702120000_add_logging_exporters_columns/migration.sql deleted file mode 100644 index e9f559aa83e..00000000000 --- a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260702120000_add_logging_exporters_columns/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- AlterTable -ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[]; - --- AlterTable -ALTER TABLE "LiteLLM_DeletedTeamTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[]; - --- AlterTable -ALTER TABLE "LiteLLM_OrganizationTable" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 60de5dc5470..37ea55f8c13 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -87,7 +87,6 @@ model LiteLLM_OrganizationTable { budget_id String metadata Json @default("{}") models String[] - logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names) spend Float @default(0.0) model_spend Json @default("{}") object_permission_id String? @@ -143,7 +142,6 @@ model LiteLLM_TeamTable { team_member_permissions String[] @default([]) access_group_ids String[] @default([]) policies String[] @default([]) - logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names) default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction budget_limits Json? // per-model budget limits for the team model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases @@ -212,7 +210,6 @@ model LiteLLM_DeletedTeamTable { team_member_permissions String[] @default([]) access_group_ids String[] @default([]) policies String[] @default([]) - logging_exporters String[] @default([]) model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases allow_team_guardrail_config Boolean @default(false) diff --git a/litellm/models/credentials.py b/litellm/models/credentials.py index add62e2c39d..6f12a4776fe 100644 --- a/litellm/models/credentials.py +++ b/litellm/models/credentials.py @@ -66,12 +66,11 @@ class CredentialInfo(BaseModel): Existing stored credentials carry arbitrary extra fields (e.g. ``custom_llm_provider``); ``extra="allow"`` preserves them. Only the fields the resolver consumes are typed: ``credential_type`` selects logging - destinations, and ``access``/``auto_enable`` decide which identities the - destination fires for. + destinations, and ``access`` decides which identities the destination fires + for. """ model_config = ConfigDict(extra="allow") credential_type: str | None = None access: CredentialAccess | None = None - auto_enable: bool = False diff --git a/litellm/models/organization.py b/litellm/models/organization.py index a38e330595f..8b2d95c3e09 100644 --- a/litellm/models/organization.py +++ b/litellm/models/organization.py @@ -5,7 +5,6 @@ Canonical definition for ``litellm_organizationtable``. Re-exported from ``litellm.proxy._types`` for backwards compatibility. """ -from collections.abc import Sequence from typing import List, Optional from litellm.models.budget import LiteLLM_BudgetTable @@ -23,7 +22,6 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase): spend: float = 0.0 metadata: Optional[dict] = None models: List[str] = [] - logging_exporters: Sequence[str] | None = None model_spend: Optional[dict] = {} created_by: str updated_by: str diff --git a/litellm/models/team.py b/litellm/models/team.py index 644f27c9eb3..f11c21a078e 100644 --- a/litellm/models/team.py +++ b/litellm/models/team.py @@ -7,7 +7,6 @@ budget-window value types and the team-model alias table). Re-exported from """ import json -from collections.abc import Sequence from datetime import datetime from typing import List, Literal, Optional, Union @@ -93,7 +92,6 @@ class LiteLLM_TeamTable(TeamBase): model_spend: Optional[dict] = {} model_max_budget: Optional[dict] = {} policies: Optional[List[str]] = None - logging_exporters: Sequence[str] | None = None allow_team_guardrail_config: Optional[bool] = False litellm_model_table: Optional[LiteLLM_ModelTable] = None object_permission: Optional[LiteLLM_ObjectPermissionTable] = None diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 25427a7f281..7064d126454 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1778,7 +1778,6 @@ class NewTeamRequest(TeamBase): tags: Optional[list] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None - logging_exporters: Sequence[str] | None = None prompts: Optional[List[str]] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None allowed_passthrough_routes: Optional[list] = None @@ -1845,7 +1844,6 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): model_aliases: Optional[dict] = None guardrails: Optional[List[str]] = None policies: Optional[List[str]] = None - logging_exporters: Sequence[str] | None = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None disable_global_guardrails: Optional[bool] = None team_member_budget: Optional[float] = None @@ -2000,7 +1998,6 @@ class NewOrganizationRequest(LiteLLM_BudgetTable): models: List = [] budget_id: Optional[str] = None metadata: Optional[dict] = None - logging_exporters: Sequence[str] | None = None model_rpm_limit: Optional[Dict[str, int]] = None model_tpm_limit: Optional[Dict[str, int]] = None @@ -2801,7 +2798,6 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable): spend: Optional[float] = None metadata: Optional[dict] = None models: Optional[List[str]] = None - logging_exporters: Sequence[str] | None = None updated_by: Optional[str] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None model_tpm_limit: Optional[Dict[str, int]] = None @@ -2841,7 +2837,6 @@ class OrganizationUpdateRequestV2(LiteLLMPydanticObjectBase): max_parallel_requests: int | None = None model_max_budget: dict | None = None budget_duration: str | None = None - logging_exporters: Sequence[str] | None = None object_permission: LiteLLM_ObjectPermissionBase | None = None diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 2f9bd5c2d72..71730f85d52 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -4,7 +4,7 @@ import json import re import time from collections import OrderedDict -from collections.abc import Awaitable, Sequence +from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from fastapi import HTTPException, Request @@ -622,82 +622,15 @@ async def _effective_org_id(user_api_key_dict: UserAPIKeyAuth) -> str | None: return getattr(team_obj, "organization_id", None) -async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth, org_id: str | None) -> frozenset[str]: - """The union of admin-assigned exporter names across the request's identity chain. - - Each level is read from its own ``logging_exporters`` column: the team via - ``get_team_object``, the org via ``get_org_object`` on the effective ``org_id`` - (token org or team fallback). Keys inherit from their team and org. Internal-user is - intentionally not a routing dimension. The assignment is an admin-owned column; - the request never supplies it. Needs a DB connection: in SDK mode there is no - identity to resolve against, so this is empty (admin-owned destinations do not - apply off the proxy). - """ - from litellm.proxy import proxy_server - from litellm.proxy.auth.auth_checks import ( - get_org_object, - get_team_object, - ) - - prisma_client = proxy_server.prisma_client - if prisma_client is None: - return frozenset() - cache = proxy_server.user_api_key_cache - span = getattr(user_api_key_dict, "parent_otel_span", None) - - def _assigned(obj: object) -> tuple[str, ...]: - assigned = getattr(obj, "logging_exporters", None) - if isinstance(assigned, (list, tuple)): - return tuple(str(name) for name in assigned) - return () - - async def _level(lookup: "Awaitable[object]") -> tuple[str, ...]: - try: - return _assigned(await lookup) - except Exception: # noqa: BLE001 # best-effort identity enrichment; a failed lookup must not block the request - return () - - team_names = ( - await _level( - get_team_object( - team_id=user_api_key_dict.team_id, - prisma_client=prisma_client, - user_api_key_cache=cache, - parent_otel_span=span, - proxy_logging_obj=proxy_server.proxy_logging_obj, - ) - ) - if user_api_key_dict.team_id - else () - ) - org_names = ( - await _level( - get_org_object( - org_id=org_id, - prisma_client=prisma_client, - user_api_key_cache=cache, - parent_otel_span=span, - proxy_logging_obj=proxy_server.proxy_logging_obj, - ) - ) - if org_id - else () - ) - return frozenset((*team_names, *org_names)) - - async def _resolve_logging_exporters( user_api_key_dict: UserAPIKeyAuth, ) -> "tuple[tuple[OtelDestinationParams, ...], tuple[str, ...]]": """Resolve the destinations this request fans out to, as (destinations, backends). - ``credential_info.access`` gates every destination: empty access grants no one, so - an empty-access destination never fires (proxy-wide requires ``access.global``). A - destination is selected when its ``access`` grants the caller AND either it is - ``auto_enable`` (fires without being named) or it is named in the identity chain's - ``logging_exporters`` (key + team + org). The access check is also the defensive - re-check on a named destination, so a stale or cross-tenant assignment can never - route traffic out. Each survivor is built via ``build_destination`` and deduped on + ``credential_info.access`` is the sole routing determinant: a destination is + selected when its ``access`` grants the caller's team/org. Empty access grants no + one, so an empty-access destination never fires (proxy-wide requires + ``access.global``). Each survivor is built via ``build_destination`` and deduped on (endpoint, headers, resource attributes). Returns ([], []) when nothing is selected (default-deny). """ @@ -710,16 +643,13 @@ async def _resolve_logging_exporters( team_id = user_api_key_dict.team_id org_id = await _effective_org_id(user_api_key_dict) - names = await _union_logging_exporter_names(user_api_key_dict, org_id) team_ids, org_ids = identity_scope(team_id, org_id) def _selected(credential: "CredentialItem") -> bool: info = parse_credential_info(credential.credential_info) if info is None or info.credential_type != "logging": return False - if not access_grants(info.access, team_ids, org_ids): - return False - return info.auto_enable or credential.credential_name in names + return access_grants(info.access, team_ids, org_ids) def _build( credential: "CredentialItem", diff --git a/litellm/proxy/management_endpoints/logging_exporter_access.py b/litellm/proxy/management_endpoints/logging_exporter_access.py index 319fb1c2924..93d65295be4 100644 --- a/litellm/proxy/management_endpoints/logging_exporter_access.py +++ b/litellm/proxy/management_endpoints/logging_exporter_access.py @@ -1,18 +1,15 @@ """Request-time routing predicate for admin-owned logging destinations. ``credential_info.access`` answers "which identities' traces may this destination -receive". It is routing scope, decoupled from enablement (a named assignment plus -the explicit ``auto_enable`` default-on flag). The request-time resolver in -``litellm_pre_call_utils`` is the consumer: at call time it checks whether the -request's team/org is granted before firing the destination. +receive". It is the sole routing determinant: at call time the resolver in +``litellm_pre_call_utils`` fires a destination for a request exactly when the +request's team/org is granted by that destination's ``access``. ``access_grants`` is the primitive: does this ``access`` reach an identity whose scope is the given set of team ids and org ids. The resolver passes a one-element scope built with ``identity_scope``. """ -from collections.abc import Sequence - from pydantic import ValidationError import litellm @@ -45,7 +42,6 @@ def identity_scope(team_id: str | None, org_id: str | None) -> tuple[frozenset[s def resolved_logging_exporter_names( - assigned: Sequence[str] | None, team_id: str | None, org_id: str | None, ) -> tuple[str, ...]: @@ -53,19 +49,16 @@ def resolved_logging_exporter_names( the team/org info pages. Mirrors the request-time resolver's selection: a logging destination is included - when its ``access`` grants the identity AND it is either ``auto_enable`` or named - in ``assigned`` (the identity's own ``logging_exporters``). Names only; endpoints, - headers, and the access map itself stay proxy-admin information. + when its ``access`` grants the identity. Names only; endpoints, headers, and the + access map itself stay proxy-admin information. """ team_ids, org_ids = identity_scope(team_id, org_id) - own = frozenset(str(name) for name in (assigned or ())) selected = tuple( credential.credential_name for credential in litellm.credential_list if (info := parse_credential_info(credential.credential_info)) is not None and info.credential_type == "logging" and access_grants(info.access, team_ids, org_ids) - and (info.auto_enable or credential.credential_name in own) ) return tuple(dict.fromkeys(selected)) diff --git a/litellm/proxy/management_endpoints/logging_exporter_validation.py b/litellm/proxy/management_endpoints/logging_exporter_validation.py index 5b586dfbd8d..5ba8dcf5c44 100644 --- a/litellm/proxy/management_endpoints/logging_exporter_validation.py +++ b/litellm/proxy/management_endpoints/logging_exporter_validation.py @@ -1,21 +1,14 @@ -"""Validation for admin-owned logging-exporter assignment on key/team/org. +"""Shape validation for an admin-owned logging destination's ``credential_info.access``. -An identity's ``metadata.logging_exporters`` binds it to admin-owned trace -destinations. Only the proxy admin may write it, and every name must be a registered -logging credential. Which identities a destination actually fires for is governed by -the destination's own ``credential_info.access``; the resolver -(``litellm_pre_call_utils``) evaluates that at request time. +Which identities a destination fires for is governed entirely by its +``credential_info.access``; the resolver (``litellm_pre_call_utils``) evaluates that at +request time. This module only checks that a write sets a well-formed ``access`` object. """ -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from fastapi import HTTPException, status -import litellm -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth - -LOGGING_EXPORTERS_KEY = "logging_exporters" - def validate_credential_access(credential_info: Mapping[str, object] | None) -> None: """Validate ``credential_info.access`` shape when the write sets one. @@ -50,123 +43,3 @@ def validate_credential_access(credential_info: Mapping[str, object] | None) -> status_code=status.HTTP_400_BAD_REQUEST, detail={"error": f"access contains unknown field(s): {sorted(unknown)}"}, ) - - -def _logging_credentials_by_name() -> Mapping[str, Mapping[str, object]]: - return { - credential.credential_name: (credential.credential_info or {}) - for credential in litellm.credential_list - if (credential.credential_info or {}).get("credential_type") == "logging" - } - - -def _logging_credential_names() -> frozenset[str]: - return frozenset(_logging_credentials_by_name()) - - -def _validate_exporters_shape_and_names(exporters: object) -> None: - """Common shape + registry check shared by every entry point.""" - if not isinstance(exporters, list): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "logging_exporters must be a list of credential names"}, - ) - known = _logging_credential_names() - unknown = [name for name in exporters if name not in known] - if unknown: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "error": ( - f"Unknown or non-logging credential(s): {unknown}. Register them " - "as logging credentials before assigning." - ) - }, - ) - - -def _exporter_value_changes( - requested_metadata: Mapping[str, object] | None, - existing_metadata: Mapping[str, object] | None, -) -> bool: - """True if the effective ``metadata.logging_exporters`` value would change. - - An update endpoint that REPLACES stored metadata with ``requested_metadata`` - will drop ``logging_exporters`` when the new payload omits it. So a write - requires authorization whenever: - - - the new metadata sets ``logging_exporters`` (the previously-handled case), OR - - the new metadata is provided but omits ``logging_exporters`` while the - stored metadata had one (removal-via-omission, Veria F4). - - Returns False when stored and requested values match exactly, or when the - update doesn't touch metadata at all. - """ - if not isinstance(requested_metadata, dict): - return False - new_has = LOGGING_EXPORTERS_KEY in requested_metadata - existing = existing_metadata.get(LOGGING_EXPORTERS_KEY) if isinstance(existing_metadata, dict) else None - existing_has = existing is not None - if not new_has and not existing_has: - return False - if new_has and not existing_has: - return True - if not new_has and existing_has: - return True - new_value = requested_metadata.get(LOGGING_EXPORTERS_KEY) - if isinstance(new_value, (list, tuple)) and isinstance(existing, (list, tuple)): - return tuple(new_value) != tuple(existing) - return new_value != existing - - -def validate_logging_exporter_field( - requested_exporters: Sequence[str] | None, - user_api_key_dict: UserAPIKeyAuth, - *, - existing_exporters: Sequence[str] | None = None, -) -> None: - """Authorize a typed ``logging_exporters`` write (proxy-admin only). - - Adapts the typed list to the metadata-shaped input the shared assignment - validator expects, so the authorization logic lives in one place. - ``requested_exporters is None`` means the field was not provided (no-op); an - empty list is an explicit clear and is gated like any other change. - ``existing_exporters`` is the stored column value, passed so a change is - detected and a non-admin cannot silently clear an admin-assigned value. - """ - requested_metadata = None if requested_exporters is None else {LOGGING_EXPORTERS_KEY: requested_exporters} - existing_metadata = None if existing_exporters is None else {LOGGING_EXPORTERS_KEY: existing_exporters} - validate_logging_exporter_assignment( - requested_metadata, - user_api_key_dict, - existing_metadata=existing_metadata, - ) - - -def validate_logging_exporter_assignment( - metadata: Mapping[str, object] | None, - user_api_key_dict: UserAPIKeyAuth, - *, - existing_metadata: Mapping[str, object] | None = None, -) -> None: - """Validate a ``metadata.logging_exporters`` write on key / team / org endpoints. - - Proxy-admin only. No-op when the update does not change the effective - ``logging_exporters`` value; otherwise a non-proxy-admin is rejected. - - Update paths replace stored metadata wholesale, so a caller could drop an - admin-assigned exporter by sending ``metadata`` without ``logging_exporters``. - Pass ``existing_metadata`` from the loaded row so removal-via-omission is gated - too (Veria F4). Every exporter name (when present) must resolve to a registered - logging credential. - """ - if not _exporter_value_changes(metadata, existing_metadata): - return - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={"error": "Only the proxy admin can assign logging exporters"}, - ) - requested = metadata.get(LOGGING_EXPORTERS_KEY) if isinstance(metadata, dict) else None - if requested is not None: - _validate_exporters_shape_and_names(requested) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 97707f1d028..ab401215866 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -335,7 +335,6 @@ async def new_organization( - organization_alias: *str* - The name of the organization. - models: *List* - The models the organization has access to. - - logging_exporters: *Optional[List[str]]* - Names of admin-owned logging destinations (credential names) this organization exports its traces to. - budget_id: *Optional[str]* - The id for a budget (tpm/rpm/max budget) for the organization. ### IF NO BUDGET ID - CREATE ONE WITH THESE PARAMS ### - max_budget: *Optional[float]* - Max budget for org @@ -388,12 +387,6 @@ async def new_organization( }' ``` """ - from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_field, - ) - - validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict) - from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, llm_router, @@ -652,12 +645,6 @@ async def update_organization( # Create validated data model data = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields) - from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_field, - ) - - validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict) - # Validate budget values are not negative if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0): raise HTTPException( @@ -862,7 +849,6 @@ async def update_organization_v2( org_column_updates: Mapping[str, object] = { **{field: field_values[field] for field in present_fields if field in _ORG_COLUMN_FIELDS}, **({"metadata": data.metadata or {}} if "metadata" in present_fields else {}), - **({"logging_exporters": data.logging_exporters or []} if "logging_exporters" in present_fields else {}), } object_permission_cleared = "object_permission" in present_fields and data.object_permission is None @@ -1124,7 +1110,6 @@ async def info_organization( response_pydantic_obj = LiteLLM_OrganizationTableWithMembers.model_validate(response.model_dump()) response_pydantic_obj.resolved_logging_exporters = resolved_logging_exporter_names( - response_pydantic_obj.logging_exporters, None, organization_id, ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 979ad25c7c9..769ae085d33 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -957,7 +957,6 @@ async def new_team( - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias) - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails) - policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies) - - logging_exporters: Optional[List[str]] - Names of admin-owned logging destinations (credential names) this team exports its traces to. - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key. - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission. - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member. @@ -1007,9 +1006,6 @@ async def new_team( ``` """ try: - from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_field, - ) from litellm.proxy.management_helpers.audit_logs import ( get_audit_log_changed_by, ) @@ -1022,9 +1018,6 @@ async def new_team( user_api_key_cache, ) - if data.logging_exporters is not None: - validate_logging_exporter_field(data.logging_exporters, user_api_key_dict) - if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) @@ -1632,7 +1625,6 @@ async def update_team( - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias) - guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails) - policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies) - - logging_exporters: Optional[List[str]] - Names of admin-owned logging destinations (credential names) this team exports its traces to. - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key. - object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission. - team_member_budget: Optional[float] - The maximum budget allocated to an individual team member. @@ -1676,9 +1668,6 @@ async def update_team( ``` """ try: - from litellm.proxy.management_endpoints.logging_exporter_validation import ( - validate_logging_exporter_field, - ) from litellm.proxy.proxy_server import ( litellm_proxy_admin_name, llm_router, @@ -1733,13 +1722,6 @@ async def update_team( user_api_key_dict=user_api_key_dict, ) - if data.logging_exporters is not None: - validate_logging_exporter_field( - data.logging_exporters, - user_api_key_dict, - existing_exporters=getattr(existing_team_row, "logging_exporters", None), - ) - _check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team") if data.soft_budget is not None: @@ -3658,7 +3640,6 @@ async def team_info( await _resolve_team_access_group_resources(_team_info) _team_info.resolved_logging_exporters = resolved_logging_exporter_names( - _team_info.logging_exporters, team_id, _team_info.organization_id, ) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 60de5dc5470..37ea55f8c13 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -87,7 +87,6 @@ model LiteLLM_OrganizationTable { budget_id String metadata Json @default("{}") models String[] - logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names) spend Float @default(0.0) model_spend Json @default("{}") object_permission_id String? @@ -143,7 +142,6 @@ model LiteLLM_TeamTable { team_member_permissions String[] @default([]) access_group_ids String[] @default([]) policies String[] @default([]) - logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names) default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction budget_limits Json? // per-model budget limits for the team model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases @@ -212,7 +210,6 @@ model LiteLLM_DeletedTeamTable { team_member_permissions String[] @default([]) access_group_ids String[] @default([]) policies String[] @default([]) - logging_exporters String[] @default([]) model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases allow_team_guardrail_config Boolean @default(false) diff --git a/schema.prisma b/schema.prisma index 60de5dc5470..37ea55f8c13 100644 --- a/schema.prisma +++ b/schema.prisma @@ -87,7 +87,6 @@ model LiteLLM_OrganizationTable { budget_id String metadata Json @default("{}") models String[] - logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this org (credential names) spend Float @default(0.0) model_spend Json @default("{}") object_permission_id String? @@ -143,7 +142,6 @@ model LiteLLM_TeamTable { team_member_permissions String[] @default([]) access_group_ids String[] @default([]) policies String[] @default([]) - logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this team (credential names) default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction budget_limits Json? // per-model budget limits for the team model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases @@ -212,7 +210,6 @@ model LiteLLM_DeletedTeamTable { team_member_permissions String[] @default([]) access_group_ids String[] @default([]) policies String[] @default([]) - logging_exporters String[] @default([]) model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases allow_team_guardrail_config Boolean @default(false) 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 63afec043e4..64c471474ba 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 @@ -29,18 +29,16 @@ def test_parse_none_for_non_dict(): assert parse_credential_info(["a"]) is None -def test_parse_typed_access_and_auto_enable(): +def test_parse_typed_access(): info = parse_credential_info( { "credential_type": "logging", "description": "arize", - "auto_enable": True, "access": {"global": True, "teams": ["t1"], "orgs": ["o1"]}, } ) assert info is not None assert info.credential_type == "logging" - assert info.auto_enable is True assert info.access is not None assert info.access.global_ is True assert info.access.teams == ("t1",) @@ -51,7 +49,6 @@ def test_parse_missing_access_is_none_not_error(): info = parse_credential_info({"credential_type": "logging"}) assert info is not None assert info.access is None - assert info.auto_enable is False def test_parse_malformed_access_fails_closed(): @@ -101,50 +98,41 @@ def test_access_grants_not_global_when_false(): # --- routing scope decided entirely by access ------------------------------- # -# Routing is access-only. auto_enable does not widen it: an empty-access -# destination fires for no one regardless of auto_enable (empty access = -# deny-all). Proxy-wide routing must be requested explicitly with -# access.global=True. +# Routing is access-only: a destination fires for exactly the identities its +# access grants. Empty access fires for no one (deny-all); proxy-wide routing +# must be requested explicitly with access.global=True. -def test_empty_access_is_deny_all_even_with_auto_enable(): - """Empty access grants no one, even when auto_enable=True: not proxy-wide.""" - info = CredentialInfo(credential_type="logging", auto_enable=True) +def test_empty_access_is_deny_all(): + """Empty access grants no one: not proxy-wide.""" + info = CredentialInfo(credential_type="logging") assert access_grants(info.access, frozenset(), frozenset()) is False assert access_grants(info.access, frozenset({"any-team"}), frozenset()) is False assert access_grants(info.access, frozenset(), frozenset({"any-org"})) is False def test_global_access_is_proxy_wide(): - """access.global=True is proxy-wide regardless of auto_enable.""" - info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(global_=True)) + """access.global=True reaches every identity.""" + info = CredentialInfo(credential_type="logging", access=_access(global_=True)) assert access_grants(info.access, frozenset({"t1"}), frozenset()) is True assert access_grants(info.access, frozenset(), frozenset()) is True - manual = CredentialInfo(credential_type="logging", access=_access(global_=True)) - assert access_grants(manual.access, frozenset(), frozenset()) is True -def test_auto_enable_team_scoped(): - """auto_enable=True + access.teams=[t1] fires only for t1 identities.""" - info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(teams=["t1"])) +def test_access_team_scoped(): + """access.teams=[t1] fires only for t1 identities.""" + info = CredentialInfo(credential_type="logging", access=_access(teams=["t1"])) assert access_grants(info.access, frozenset({"t1"}), frozenset()) is True assert access_grants(info.access, frozenset({"t2"}), frozenset()) is False assert access_grants(info.access, frozenset(), frozenset()) is False -def test_auto_enable_org_scoped(): - """auto_enable=True + access.orgs=[o1] fires only for o1 identities.""" - info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(orgs=["o1"])) +def test_access_org_scoped(): + """access.orgs=[o1] fires only for o1 identities.""" + info = CredentialInfo(credential_type="logging", access=_access(orgs=["o1"])) assert access_grants(info.access, frozenset(), frozenset({"o1"})) is True assert access_grants(info.access, frozenset(), frozenset({"o2"})) is False -def test_access_scoped_when_not_auto_enable(): - info = CredentialInfo(credential_type="logging", access=_access(teams=["t1"])) - assert access_grants(info.access, frozenset({"t1"}), frozenset()) is True - assert access_grants(info.access, frozenset({"t2"}), frozenset()) is False - - def test_denies_when_no_access(): info = CredentialInfo(credential_type="logging") assert access_grants(info.access, frozenset({"t1"}), frozenset({"o1"})) is False @@ -168,32 +156,31 @@ def test_identity_scope_empty_for_none(): # --- resolved_logging_exporter_names: the /team/info + /organization/info disclosure -- -def _cred(name, access=None, auto=False, ctype="logging"): - info = {"credential_type": ctype, "auto_enable": auto} +def _cred(name, access=None, ctype="logging"): + info = {"credential_type": ctype} if access is not None: info["access"] = access return CredentialItem(credential_name=name, credential_values={}, credential_info=info) -def test_resolved_names_mirror_the_resolver(monkeypatch): - """Included: auto+granted, named+granted. Excluded: granted-but-manual-unnamed, - named-but-not-granted, empty-access even with auto, provider credentials.""" +def test_resolved_names_are_access_only(monkeypatch): + """A destination name appears iff its access grants the (team_id, org_id). + Included: team-granted, org-granted, global. Excluded: empty-access, + granted-but-not-logging (provider) credentials, access for another team.""" monkeypatch.setattr( litellm, "credential_list", [ - _cred("auto-team", access={"teams": ["t1"]}, auto=True), - _cred("manual-team", access={"teams": ["t1"]}, auto=False), - _cred("named-manual", access={"teams": ["t1"]}, auto=False), - _cred("named-ungranted", access={"teams": ["other"]}, auto=False), - _cred("empty-auto", auto=True), - _cred("global-auto", access={"global": True}, auto=True), - _cred("org-auto", access={"orgs": ["o1"]}, auto=True), - _cred("provider", access={"global": True}, auto=True, ctype=None), + _cred("team-granted", access={"teams": ["t1"]}), + _cred("team-other", access={"teams": ["other"]}), + _cred("org-granted", access={"orgs": ["o1"]}), + _cred("empty-access"), + _cred("global-access", access={"global": True}), + _cred("provider", access={"global": True}, ctype=None), ], ) - names = resolved_logging_exporter_names(["named-manual", "named-ungranted"], "t1", "o1") - assert names == ("auto-team", "named-manual", "global-auto", "org-auto") + names = resolved_logging_exporter_names("t1", "o1") + assert names == ("team-granted", "org-granted", "global-access") def test_resolved_names_empty_scope_gets_global_only(monkeypatch): @@ -201,13 +188,13 @@ def test_resolved_names_empty_scope_gets_global_only(monkeypatch): litellm, "credential_list", [ - _cred("global-auto", access={"global": True}, auto=True), - _cred("team-auto", access={"teams": ["t1"]}, auto=True), + _cred("global-access", access={"global": True}), + _cred("team-scoped", access={"teams": ["t1"]}), ], ) - assert resolved_logging_exporter_names(None, None, None) == ("global-auto",) + assert resolved_logging_exporter_names(None, None) == ("global-access",) def test_resolved_names_empty_registry(monkeypatch): monkeypatch.setattr(litellm, "credential_list", []) - assert resolved_logging_exporter_names(["anything"], "t1", "o1") == () + assert resolved_logging_exporter_names("t1", "o1") == () diff --git a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py index c149b613016..35fcbfa6a77 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py +++ b/tests/test_litellm/proxy/management_endpoints/test_logging_exporter_validation.py @@ -1,218 +1,21 @@ -"""Validation for admin-owned logging-exporter assignment on key/team/org. +"""Tests for ``validate_credential_access`` -- the shape check on a logging +destination's ``credential_info.access`` at create/update time. -The single ``validate_logging_exporter_assignment`` runs across every write path -(``/team/new``, ``/team/update``, ``/key/generate``, ``/key/update``, -``/organization/*``). Assigning ``logging_exporters`` is proxy-admin only: a -non-proxy-admin write is rejected, and every named exporter must resolve to a -registered logging credential. +Which identities a destination fires for is governed entirely by ``access`` and +evaluated by the request-time resolver; there is no separate assignment/enable +surface, so this module only guards that a write stores a well-formed ``access``. """ -import os -import sys - import pytest from fastapi import HTTPException -sys.path.insert(0, os.path.abspath("../../../..")) - -import litellm -from litellm.models.credentials import CredentialItem -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.management_endpoints.logging_exporter_validation import ( validate_credential_access, - validate_logging_exporter_assignment, - validate_logging_exporter_field, ) -@pytest.fixture -def _registry(): - original = litellm.credential_list - litellm.credential_list = [ - # global: visible to (and assignable by) every scope. - CredentialItem( - credential_name="langfuse-eu", - credential_values={}, - credential_info={ - "credential_type": "logging", - "description": "langfuse_otel", - "access": {"global": True}, - }, - ), - # scoped to one team / one org: assignable only within that scope. - CredentialItem( - credential_name="arize-ds", - credential_values={}, - credential_info={ - "credential_type": "logging", - "description": "arize", - "access": {"teams": ["ds-team"], "orgs": ["ds-org"]}, - }, - ), - # proxy-wide auto default: access.global makes it visible to every scope, - # auto_enable makes it fire without being named. - CredentialItem( - credential_name="central-default", - credential_values={}, - credential_info={ - "credential_type": "logging", - "description": "arize", - "auto_enable": True, - "access": {"global": True}, - }, - ), - CredentialItem( - credential_name="openai-key", - credential_values={}, - credential_info={"custom_llm_provider": "openai"}, # provider credential - ), - ] - try: - yield - finally: - litellm.credential_list = original - - -def _admin(): - return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.PROXY_ADMIN) - - -def _non_admin(): - return UserAPIKeyAuth(api_key="k", user_role=LitellmUserRoles.INTERNAL_USER) - - -def _ok(metadata): - return {"logging_exporters": metadata} - - -# --- Role allow paths ------------------------------------------------------- - - -def test_proxy_admin_always_allowed(_registry): - """No flags needed; proxy_admin role suffices.""" - validate_logging_exporter_assignment(_ok(["langfuse-eu"]), _admin()) - - -def test_non_admin_with_no_flags_is_forbidden(_registry): - """The headline deny: internal_user with no team/org admin context.""" - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_assignment(_ok(["langfuse-eu"]), _non_admin()) - assert exc.value.status_code == 403 - - -# --- Shape / registry checks (run regardless of who's calling) -------------- - - -def test_unknown_credential_rejected_for_admin(_registry): - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_assignment(_ok(["does-not-exist"]), _admin()) - assert exc.value.status_code == 400 - - -def test_provider_credential_rejected(_registry): - """openai-key exists but is provider-typed, not a logging destination.""" - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_assignment(_ok(["openai-key"]), _admin()) - assert exc.value.status_code == 400 - - -def test_non_list_is_rejected(_registry): - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_assignment( - {"logging_exporters": "langfuse-eu"}, _admin() - ) - assert exc.value.status_code == 400 - - -def test_noop_when_field_absent(_registry): - """An update that does not touch logging_exporters skips the gate even - for a non-admin with no flags.""" - validate_logging_exporter_assignment({"some_other_key": 1}, _non_admin()) - validate_logging_exporter_assignment(None, _non_admin()) - - -# --- Veria F4: removal-via-omission ---------------------------------------- -# -# Update endpoints replace stored metadata wholesale. A caller can wipe an -# admin-assigned `logging_exporters` by sending a `metadata` payload that -# omits the field. The validator must catch this when ``existing_metadata`` -# is passed. - - -def test_removal_via_omission_blocked_for_non_admin(_registry): - """A non-admin with no flags cannot wipe an admin-assigned exporter by - submitting metadata without logging_exporters.""" - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_assignment( - {"some_other_key": 1}, # no logging_exporters in the new payload - _non_admin(), - existing_metadata={"logging_exporters": ["langfuse-eu"]}, - ) - assert exc.value.status_code == 403 - - -def test_removal_via_omission_allowed_for_proxy_admin(_registry): - """Proxy admin may drop the exporter via omission.""" - validate_logging_exporter_assignment( - {"some_other_key": 1}, - _admin(), - existing_metadata={"logging_exporters": ["langfuse-eu"]}, - ) - - -def test_explicit_empty_list_blocked_for_non_admin(_registry): - """A non-admin submitting `logging_exporters: []` over a non-empty stored - value is a removal write and must be gated.""" - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_assignment( - {"logging_exporters": []}, - _non_admin(), - existing_metadata={"logging_exporters": ["langfuse-eu"]}, - ) - assert exc.value.status_code == 403 - - -def test_explicit_null_blocked_for_non_admin(_registry): - """`logging_exporters: null` over a non-empty stored value is also a - removal; the validator's shape check would reject it as non-list, but - F4's authorization gate must fire first.""" - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_assignment( - {"logging_exporters": None}, - _non_admin(), - existing_metadata={"logging_exporters": ["langfuse-eu"]}, - ) - assert exc.value.status_code == 403 - - -def test_unchanged_value_is_noop(_registry): - """A metadata payload that re-sends the SAME logging_exporters value is - a noop and skips the gate even for a non-admin -- there is no net change - to authorize.""" - validate_logging_exporter_assignment( - {"logging_exporters": ["langfuse-eu"]}, - _non_admin(), - existing_metadata={"logging_exporters": ["langfuse-eu"]}, - ) - - -def test_omitted_on_both_sides_is_noop(_registry): - """A metadata update that doesn't touch logging_exporters on a row that - never had one is a noop.""" - validate_logging_exporter_assignment( - {"some_other_key": 1}, - _non_admin(), - existing_metadata={"some_other_key": 0}, - ) - - -# --- validate_credential_access --------------------------------------------- - - def test_validate_credential_access_accepts_valid_object(): - validate_credential_access( - {"access": {"global": False, "teams": ["t1", "t2"], "orgs": ["o1"]}} - ) + validate_credential_access({"access": {"global": False, "teams": ["t1", "t2"], "orgs": ["o1"]}}) def test_validate_credential_access_noop_without_access(): @@ -243,49 +46,3 @@ def test_validate_credential_access_rejects_unknown_field(): validate_credential_access({"access": {"global": True, "legacy_field": "x"}}) assert exc.value.status_code == 400 assert "legacy_field" in exc.value.detail["error"] - - -# --- validate_logging_exporter_field (the column-backed adapter) ------------ -# -# The endpoints now pass a typed list off the request's ``logging_exporters`` -# field instead of a metadata dict. The adapter must gate the same way, and the -# typed field's None-means-omitted semantics must not open a bypass. - - -def test_field_none_is_noop_for_non_admin(_registry): - """A request that omits logging_exporters (None) must not require authorization.""" - validate_logging_exporter_field(None, _non_admin()) - - -def test_field_set_by_non_admin_is_forbidden(_registry): - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_field(["langfuse-eu"], _non_admin()) - assert exc.value.status_code == 403 - - -def test_field_proxy_admin_can_assign(_registry): - """Proxy admin may assign any registered logging destination.""" - validate_logging_exporter_field(["arize-ds"], _admin()) - - -def test_field_empty_clear_over_existing_is_gated_for_non_admin(_registry): - """Clearing an admin-assigned value ([] over a non-empty stored column) is a - change and must be authorized; a non-admin cannot silently wipe it.""" - with pytest.raises(HTTPException) as exc: - validate_logging_exporter_field( - [], - _non_admin(), - existing_exporters=["langfuse-eu"], - ) - assert exc.value.status_code == 403 - - -def test_field_unchanged_value_is_noop(_registry): - """Re-sending the same column value is a no-op even for a non-admin.""" - validate_logging_exporter_field( - ["langfuse-eu"], - _non_admin(), - existing_exporters=["langfuse-eu"], - ) - - diff --git a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py index c82c831b3e4..7ed123f6cdf 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py @@ -779,50 +779,6 @@ async def test_v2_update_metadata_replaces_not_merges(monkeypatch): assert json.loads(write_data["metadata"]) == {"a": 1} -@pytest.mark.asyncio -async def test_v2_update_writes_logging_exporters_to_org_column(monkeypatch): - """Assigning logging_exporters writes the credential names to the org column, not the budget row or metadata.""" - prisma = await _run_update_organization_v2( - monkeypatch, - body={"logging_exporters": ["arize-prod", "langfuse-eu"]}, - existing_budget_id="budget-1", - existing_metadata={"keep": "me"}, - ) - - prisma.db.litellm_budgettable.update.assert_not_awaited() - write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"] - assert write_data["logging_exporters"] == ["arize-prod", "langfuse-eu"] - assert "metadata" not in write_data - - -@pytest.mark.asyncio -async def test_v2_update_clears_logging_exporters_with_empty_list(monkeypatch): - """A null logging_exporters clears the org's assignments by writing an empty list to the non-nullable column.""" - prisma = await _run_update_organization_v2( - monkeypatch, - body={"logging_exporters": None}, - existing_budget_id="budget-1", - existing_metadata={"keep": "me"}, - ) - - write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"] - assert write_data["logging_exporters"] == [] - - -@pytest.mark.asyncio -async def test_v2_update_omitted_logging_exporters_not_written(monkeypatch): - """Omitting logging_exporters leaves the existing assignments untouched.""" - prisma = await _run_update_organization_v2( - monkeypatch, - body={"organization_alias": "renamed"}, - existing_budget_id="budget-1", - existing_metadata={"keep": "me"}, - ) - - write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"] - assert "logging_exporters" not in write_data - - @pytest.mark.asyncio async def test_v2_rejects_null_clear_of_non_nullable_fields(monkeypatch): """organization_alias and models are non-nullable columns, so a null clear is a 422, not a 500.""" diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 9054b65b86b..dbaa69fc42a 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -5156,7 +5156,7 @@ def _seeded_logging_credentials(): credential_info={ "credential_type": "logging", "description": "langfuse_otel", - "access": {"global": True}, + "access": {"teams": ["team-x"]}, }, ), CredentialItem( @@ -5169,7 +5169,25 @@ def _seeded_logging_credentials(): credential_info={ "credential_type": "logging", "description": "arize", - "access": {"global": True}, + "access": {"teams": ["team-az"]}, + }, + ), + CredentialItem( + credential_name="generic-org", + credential_values={"otel_endpoint": "http://collector.internal/v1/traces"}, + credential_info={ + "credential_type": "logging", + "description": "generic", + "access": {"orgs": ["org-1"]}, + }, + ), + CredentialItem( + credential_name="empty-deny", + credential_values={"otel_endpoint": "http://never/v1/traces"}, + credential_info={ + "credential_type": "logging", + "description": "generic", + "access": {}, }, ), # A provider credential that must never resolve as a logging destination. @@ -5189,14 +5207,12 @@ def _auth(token="hashed-key", org_id=None, team_id="team-x"): return UserAPIKeyAuth(api_key="hashed-key", token=token, org_id=org_id, team_id=team_id) -def _patch_identity(monkeypatch, *, key=(), team=(), org=(), team_org_id=None): - """Route the resolver's identity lookups to ``logging_exporters`` columns. +def _patch_identity(monkeypatch, *, team_org_id=None, **_ignored): + """Connect a prisma client and route the resolver's only remaining DB lookup. - Assignments now live on typed columns, so the resolver reads each level from - its own DB object. This connects a prisma client and patches - ``get_key_object`` / ``get_team_object`` / ``get_org_object`` to return objects - carrying the given ``logging_exporters``. ``team_org_id`` sets the team's - ``organization_id`` for the token-has-no-org_id org fallback. + Selection is access-only, read from ``litellm.credential_list``. The sole lookup + left is ``_effective_org_id`` resolving the team's organization when the token + carries no ``org_id``, so ``get_team_object`` returns just ``organization_id``. """ from types import SimpleNamespace @@ -5205,49 +5221,44 @@ def _patch_identity(monkeypatch, *, key=(), team=(), org=(), team_org_id=None): monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock()) - monkeypatch.setattr( - auth_checks, "get_key_object", AsyncMock(return_value=SimpleNamespace(logging_exporters=list(key))) - ) monkeypatch.setattr( auth_checks, "get_team_object", - AsyncMock(return_value=SimpleNamespace(logging_exporters=list(team), organization_id=team_org_id)), - ) - monkeypatch.setattr( - auth_checks, "get_org_object", AsyncMock(return_value=SimpleNamespace(logging_exporters=list(org))) + AsyncMock(return_value=SimpleNamespace(organization_id=team_org_id)), ) @pytest.mark.asyncio -async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials, monkeypatch): - # team assignment lives on the team's logging_exporters column. +async def test_resolve_logging_exporters_team_access(_seeded_logging_credentials, monkeypatch): + """A destination whose access grants the caller's team fires for it, and only it.""" from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - _patch_identity(monkeypatch, team=["langfuse-eu"]) - destinations, backends = await _resolve_logging_exporters(_auth()) - assert {d["endpoint"] for d in destinations} == { - "https://cloud.langfuse.com/api/public/otel" - } + _patch_identity(monkeypatch) + destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-x")) + assert {d["endpoint"] for d in destinations} == {"https://cloud.langfuse.com/api/public/otel"} assert backends == ("langfuse_otel",) @pytest.mark.asyncio -async def test_resolve_logging_exporters_unions_team_org_keys_inherit( - _seeded_logging_credentials, monkeypatch -): - # team and org are each read from their OWN logging_exporters column and union, - # deduped. Keys have no assignment column: they inherit from team/org, so a - # key-level value (patched below) must contribute nothing. +async def test_resolve_logging_exporters_org_access(_seeded_logging_credentials, monkeypatch): + """An org-scoped destination fires for a caller in that org.""" from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - _patch_identity(monkeypatch, key=["arize-prod"], team=["langfuse-eu"], org=["langfuse-eu"]) + _patch_identity(monkeypatch) + destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-none", org_id="org-1")) + assert {d["endpoint"] for d in destinations} == {"http://collector.internal/v1/traces"} + assert backends == ("generic",) - destinations, backends = await _resolve_logging_exporters(_auth(org_id="org-1")) - assert {d["endpoint"] for d in destinations} == { - "https://cloud.langfuse.com/api/public/otel", # team + org (deduped) - } - assert set(backends) == {"langfuse_otel"} +@pytest.mark.asyncio +async def test_resolve_logging_exporters_org_fallback_from_team(_seeded_logging_credentials, monkeypatch): + """When the token carries no org_id, the team's organization grants org-scoped + destinations via the ``_effective_org_id`` fallback.""" + from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters + + _patch_identity(monkeypatch, team_org_id="org-1") + destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-none", org_id=None)) + assert {d["endpoint"] for d in destinations} == {"http://collector.internal/v1/traces"} @pytest.mark.asyncio @@ -5256,8 +5267,8 @@ async def test_resolve_logging_exporters_carries_arize_project( ): from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - _patch_identity(monkeypatch, team=["arize-prod"]) - destinations, _ = await _resolve_logging_exporters(_auth()) + _patch_identity(monkeypatch) + destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-az")) assert destinations == ( { @@ -5273,25 +5284,28 @@ async def test_resolve_logging_exporters_carries_arize_project( @pytest.mark.asyncio -async def test_resolve_logging_exporters_empty_without_assignment( - _seeded_logging_credentials, +async def test_resolve_logging_exporters_empty_without_access( + _seeded_logging_credentials, monkeypatch ): + """An identity no destination's access grants gets nothing; empty access is deny-all.""" from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - destinations, backends = await _resolve_logging_exporters(_auth()) + _patch_identity(monkeypatch) + destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-none")) assert destinations == () and backends == () @pytest.mark.asyncio -async def test_resolve_logging_exporters_skips_unknown_and_provider_creds( +async def test_resolve_logging_exporters_skips_provider_creds( _seeded_logging_credentials, monkeypatch ): + """A provider credential (not credential_type=logging) is never a destination, + even for a team that resolves a real one.""" from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - # unknown name + a provider credential (not credential_type=logging) -> nothing - _patch_identity(monkeypatch, team=["does-not-exist", "openai-key"]) - destinations, backends = await _resolve_logging_exporters(_auth()) - assert destinations == () and backends == () + _patch_identity(monkeypatch) + destinations, backends = await _resolve_logging_exporters(_auth(team_id="team-az")) + assert backends == ("arize",) @pytest.mark.asyncio @@ -5304,7 +5318,7 @@ async def test_apply_admin_logging_exporters_stamps_and_activates( ) from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters - _patch_identity(monkeypatch, team=["langfuse-eu"]) + _patch_identity(monkeypatch) token = _request_destinations.set(()) data: dict = {} try: @@ -5451,218 +5465,19 @@ async def test_apply_admin_logging_exporters_registers_on_failure( from litellm.integrations.otel.plumbing.context import _request_destinations from litellm.proxy.litellm_pre_call_utils import _apply_admin_logging_exporters - _patch_identity(monkeypatch, team=["langfuse-eu", "arize-prod"]) + _patch_identity(monkeypatch) token = _request_destinations.set(()) - # Seed a pre-existing failure callback to prove backends are unioned in, not - # overwriting, and that a duplicate backend is not appended twice. data: dict = {"failure_callback": ["arize"]} try: - await _apply_admin_logging_exporters(data, _auth()) + await _apply_admin_logging_exporters(data, _auth(team_id="team-az")) for callback_list in ("success_callback", "failure_callback"): registered = data[callback_list] - assert "langfuse_otel" in registered assert "arize" in registered assert registered.count("arize") == 1 finally: _request_destinations.reset(token) -_LANGFUSE_ENDPOINT = "https://cloud.langfuse.com/api/public/otel" -_ARIZE_ENDPOINT = "https://otlp.arize.com/v1" - - -@pytest.fixture -def _seeded_logging_credentials_with_access(): - """``access`` gates enablement. ``langfuse-eu`` is granted to - ``team-eu``/``org-eu`` but never auto-fires (not auto_enable, not named); - ``arize-global`` carries ``access.global`` to prove global visibility alone - STILL does not auto-fire; ``arize-default`` is the proxy-wide auto default - (``auto_enable`` + ``access.global``). Empty access would be deny-all.""" - from litellm.models.credentials import CredentialItem - - original = litellm.credential_list - litellm.credential_list = [ - CredentialItem( - credential_name="langfuse-eu", - credential_values={ - "langfuse_host": "https://cloud.langfuse.com", - "langfuse_public_key": "pk-eu", - "langfuse_secret_key": "sk-eu", - }, - credential_info={ - "credential_type": "logging", - "description": "langfuse_otel", - "access": {"teams": ["team-eu"], "orgs": ["org-eu"]}, - }, - ), - CredentialItem( - credential_name="arize-global", - credential_values={"arize_space_id": "S", "arize_api_key": "K"}, - credential_info={ - "credential_type": "logging", - "description": "arize", - "access": {"global": True}, - }, - ), - CredentialItem( - credential_name="arize-default", - credential_values={"arize_space_id": "D", "arize_api_key": "K"}, - credential_info={ - "credential_type": "logging", - "description": "arize", - "auto_enable": True, - "access": {"global": True}, - }, - ), - ] - try: - yield - finally: - litellm.credential_list = original - - -@pytest.mark.asyncio -async def test_resolve_grant_does_not_auto_enable( - _seeded_logging_credentials_with_access, -): - """Granting a destination to a team (or globally) must NOT enable it for the - team's requests. The pre-fix resolver fired on access alone; this pins that - access is now visibility-only. ``arize-default`` (auto_enable) is the only thing - that fires for an unassigned team-eu caller.""" - from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - - destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-eu")) - - # langfuse-eu is granted to team-eu and arize-global is access.global, yet - # neither fires because neither is named; only the explicit auto_enable does. - assert {d["endpoint"] for d in destinations} == {_ARIZE_ENDPOINT} - - -@pytest.mark.asyncio -async def test_resolve_name_with_grant_enables( - _seeded_logging_credentials_with_access, monkeypatch -): - """Naming a destination the caller is granted enables it (alongside the - auto_enable default).""" - from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - - _patch_identity(monkeypatch, team=["langfuse-eu"]) - destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-eu")) - - assert {d["endpoint"] for d in destinations} == {_LANGFUSE_ENDPOINT, _ARIZE_ENDPOINT} - - -@pytest.mark.asyncio -async def test_resolve_name_without_visibility_is_dropped( - _seeded_logging_credentials_with_access, monkeypatch -): - """A name that points at a destination NOT visible to the request identity is - defensively ignored, so a stale or cross-tenant assignment can never route - traffic out. team-other names langfuse-eu (granted only to team-eu).""" - from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - - _patch_identity(monkeypatch, team=["langfuse-eu"]) - destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-other")) - - # langfuse-eu dropped (not visible to team-other); only auto_enable survives. - assert {d["endpoint"] for d in destinations} == {_ARIZE_ENDPOINT} - - -@pytest.mark.asyncio -async def test_resolve_auto_enable_empty_access_is_deny_all(monkeypatch): - """The core of the empty-access hardening: an auto_enable destination with no - access grants fires for NO ONE (empty access = deny-all, not proxy-wide). - Mutating the resolver to treat empty access as proxy-wide re-fires it here.""" - from litellm.models.credentials import CredentialItem - from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - - original = litellm.credential_list - litellm.credential_list = [ - CredentialItem( - credential_name="arize-empty-auto", - credential_values={"arize_space_id": "E", "arize_api_key": "K"}, - credential_info={ - "credential_type": "logging", - "description": "arize", - "auto_enable": True, - }, - ), - ] - try: - for auth in (_auth(team_id="team-x"), _auth(org_id="org-y"), _auth()): - destinations, _ = await _resolve_logging_exporters(auth) - assert destinations == () - finally: - litellm.credential_list = original - - -@pytest.mark.asyncio -async def test_resolve_access_global_alone_does_not_fire( - _seeded_logging_credentials_with_access, -): - """The headline regression: a destination with access.global but no auto_enable - and no name must NOT fire for an unassigned caller. Mutating the resolver back to - selecting on access alone re-adds arize-global here and fails this test.""" - from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - - # an org with no grants, no names: only the auto_enable default fires. - destinations, _ = await _resolve_logging_exporters(_auth(org_id="org-unrelated")) - - # exactly one destination -- the auto_enable arize-default (space_id "D"). - # arize-global shares the arize endpoint but carries space_id "S"; if access.global - # auto-fired it would survive as a SECOND destination here. - assert len(destinations) == 1 - assert destinations[0]["endpoint"] == _ARIZE_ENDPOINT - assert destinations[0]["headers"]["space_id"] == "D" - - -@pytest.mark.asyncio -async def test_resolve_logging_exporters_access_default_deny( - _seeded_logging_credentials, -): - """With no auto_enable and no identity assignment, nothing resolves even though - the seeded creds are access.global-visible -- visibility never invents a - destination.""" - from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - - destinations, backends = await _resolve_logging_exporters( - _auth(team_id="team-eu", org_id="org-eu") - ) - assert destinations == () and backends == () - - -@pytest.mark.asyncio -async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch): - """A team key whose token carries no org_id must still resolve an org-scoped - destination, via the team's organization_id. The write gate loads the team and - accepts the assignment, so the resolver must agree (M1); without the fallback the - org-granted destination is named but invisible (org_id None) and silently dropped. - Reverting _effective_org_id to user_api_key_dict.org_id fails this test.""" - from litellm.models.credentials import CredentialItem - from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters - - original = litellm.credential_list - litellm.credential_list = [ - CredentialItem( - credential_name="arize-org", - credential_values={"arize_space_id": "S", "arize_api_key": "K"}, - credential_info={ - "credential_type": "logging", - "description": "arize", - "access": {"orgs": ["org-7"]}, - }, - ), - ] - # The key's token has no org_id; the team it belongs to is in org-7, and the - # arize-org destination is named on the team's logging_exporters column. - _patch_identity(monkeypatch, team=["arize-org"], team_org_id="org-7") - try: - destinations, _ = await _resolve_logging_exporters(_auth(team_id="team-x")) - assert {d["endpoint"] for d in destinations} == {"https://otlp.arize.com/v1"} - finally: - litellm.credential_list = original - - @pytest.mark.asyncio async def test_add_litellm_data_to_request_merges_metadata_tags_on_responses_route(): """Regression for #31584: user-supplied metadata.tags must be merged into diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx index 74b82f36702..90a935d77f4 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.test.tsx @@ -118,7 +118,7 @@ describe("LoggingCallbacksTable", () => { expect(screen.getByText("Failure")).toBeInTheDocument(); }); - it("renders a global destination's scope and manual assignment mode", () => { + it("renders a global destination's scope", () => { render( { />, ); expect(screen.getByText("Global access")).toBeInTheDocument(); - expect(screen.getByText("Manual assignment")).toBeInTheDocument(); expect(screen.queryByText("Success")).not.toBeInTheDocument(); }); @@ -158,45 +157,6 @@ describe("LoggingCallbacksTable", () => { expect(screen.getByText("org: o1")).toBeInTheDocument(); }); - it("renders auto-enable mode for a destination", () => { - render( - , - ); - expect(screen.getByText("Auto-enabled")).toBeInTheDocument(); - }); - - it("renders disabled mode for an auto-enable destination with no access grants", () => { - render( - , - ); - expect(screen.getByText("Disabled")).toBeInTheDocument(); - expect(screen.queryByText("Auto-enabled")).not.toBeInTheDocument(); - }); - it("a destination row edits access and deletes without exposing callback actions", async () => { const user = userEvent.setup(); const onEditAccess = vi.fn(); diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx index 3be6b95726c..efb4ab2c2f3 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTableColumns.tsx @@ -45,34 +45,6 @@ function callbackModeTone(mode: string): StatusTone { return "info"; } -function destinationMode(record: AlertingObject) { - if (record.autoEnable !== true) { - return Manual assignment; - } - const access = record.access; - const hasExplicitGrants = [ - access?.global === true, - (access?.teams?.length ?? 0) > 0, - (access?.orgs?.length ?? 0) > 0, - ].some(Boolean); - if (!hasExplicitGrants) { - return ( - - ); - } - return ( - - ); -} - function ScopeCell({ callback }: { callback: AlertingObject }) { const scope = callback.resolvedScope; const hasResolvedScope = scope?.global === true || [...(scope?.teams ?? []), ...(scope?.orgs ?? [])].length > 0; @@ -195,7 +167,7 @@ export const getLoggingCallbacksTableColumns = ({ enableSorting: false, cell: ({ row }) => { if (isDestination(row.original)) { - return destinationMode(row.original); + return ; } const mode = callbackRowMode(row.original); return ; diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts index 006b9ebe5c4..0c69362f908 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts @@ -14,14 +14,9 @@ export interface AlertingObject { credentialName?: string; destinationLabel?: string; access?: CredentialAccess; - // True when credential_info.auto_enable=true: destination exports on every - // request without needing explicit key/team/org assignment. Distinct from - // access.global (which controls visibility/assignability, not routing). - autoEnable?: boolean; - // The union of identities that route to this destination, resolved at render - // time from both directions (destination-side credential_info.access AND - // identity-side metadata.logging_exporters). Display labels only -- ids are - // not surfaced here. global=true bypasses the lists. + // The set of identities that route to this destination, resolved at render + // time from credential_info.access. Display labels only -- ids are not + // surfaced here. global=true bypasses the lists. resolvedScope?: ResolvedScope; } diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index a699500e6e2..20e9e78e7e4 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -47,7 +47,6 @@ interface EditTeamModalProps { } import DeleteResourceModal from "./common_components/DeleteResourceModal"; -import { LoggingExportersFormItem } from "./logging_credentials/LoggingExportersSelect"; import { teamCreateCall } from "./networking"; import { ModelSelect } from "./ModelSelect/ModelSelect"; @@ -342,13 +341,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser formValues.metadata = JSON.stringify(metadata); } - // logging_exporters is a top-level typed field on the team (its own column), - // not part of the free-form metadata blob; send it as-is when set so the user - // can assign destinations from the new-team form (instead of create-then-edit). - if (!Array.isArray(formValues.logging_exporters) || formValues.logging_exporters.length === 0) { - delete formValues.logging_exporters; - } - if (formValues.secret_manager_settings) { if (typeof formValues.secret_manager_settings === "string") { if (formValues.secret_manager_settings.trim() === "") { @@ -1101,10 +1093,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser Logging Settings -
= ({ value = {}, o <> onChange({ ...value, global })} /> ({ - useCredentials: () => mockUseCredentials(), -})); - -vi.mock("antd", async () => { - const React = await import("react"); - function Select(props: any) { - const { value, onChange, options, notFoundContent } = props; - return React.createElement( - "div", - { "data-testid": "logging-exporters-select" }, - React.createElement( - "ul", - null, - (options ?? []).map((opt: any) => - React.createElement("li", { key: opt.value, "data-testid": "option" }, opt.label), - ), - ), - options && options.length === 0 ? React.createElement("div", { "data-testid": "empty" }, notFoundContent) : null, - React.createElement( - "button", - { "data-testid": "pick-first", onClick: () => onChange?.(options?.[0] ? [options[0].value] : []) }, - "pick first", - ), - React.createElement("div", { "data-testid": "value" }, JSON.stringify(value ?? [])), - ); - } - return { Select }; -}); - -beforeEach(() => { - mockUseCredentials.mockReset(); -}); - -describe("LoggingExportersSelect", () => { - it("only surfaces credentials whose credential_type is 'logging'", () => { - mockUseCredentials.mockReturnValue({ - data: { - credentials: [ - { - credential_name: "poc-langfuse", - credential_info: { credential_type: "logging", host: "https://cloud.langfuse.com" }, - }, - { - credential_name: "poc-arize", - credential_info: { credential_type: "logging" }, - }, - { - credential_name: "openai-prod", - credential_info: { custom_llm_provider: "openai" }, - }, - ], - }, - }); - - render( {}} />); - - const options = screen.getAllByTestId("option").map((el) => el.textContent); - expect(options).toEqual(["poc-langfuse (https://cloud.langfuse.com)", "poc-arize"]); - }); - - it("renders empty-state copy when no logging destinations exist", () => { - mockUseCredentials.mockReturnValue({ - data: { - credentials: [ - { - credential_name: "openai-prod", - credential_info: { custom_llm_provider: "openai" }, - }, - ], - }, - }); - - render( {}} />); - - expect(screen.queryAllByTestId("option")).toHaveLength(0); - expect(screen.getByTestId("empty").textContent).toMatch(/Create one under Settings/i); - }); - - it("shows exactly the logging destinations the backend returned, without any client-side scope filtering", () => { - // GET /credentials is proxy-admin only and returns every destination; the - // picker (which itself renders only for a proxy admin) must show every - // logging-typed destination in the response verbatim regardless of its - // access shape, since access controls request-time routing, not what the - // admin may assign. This response mixes access shapes to prove none are - // dropped locally. - mockUseCredentials.mockReturnValue({ - data: { - credentials: [ - { credential_name: "team-scoped", credential_info: { credential_type: "logging", access: { teams: ["t"] } } }, - { credential_name: "org-scoped", credential_info: { credential_type: "logging", access: { orgs: ["o"] } } }, - { credential_name: "everyone", credential_info: { credential_type: "logging", access: { global: true } } }, - { credential_name: "always-on", credential_info: { credential_type: "logging", auto_enable: true } }, - { credential_name: "provider", credential_info: { custom_llm_provider: "openai" } }, - ], - }, - }); - - render( {}} />); - - const options = screen.getAllByTestId("option").map((el) => el.textContent); - // every logging destination the backend returned, and only those (provider dropped) - expect(options).toEqual(["team-scoped", "org-scoped", "everyone", "always-on"]); - }); -}); diff --git a/ui/litellm-dashboard/src/components/logging_credentials/LoggingExportersSelect.tsx b/ui/litellm-dashboard/src/components/logging_credentials/LoggingExportersSelect.tsx deleted file mode 100644 index fe4a745ee02..00000000000 --- a/ui/litellm-dashboard/src/components/logging_credentials/LoggingExportersSelect.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Form, Select } from "antd"; -import React from "react"; - -import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { isProxyAdminRole } from "@/utils/roles"; - -interface LoggingExportersSelectProps { - value?: string[]; - onChange?: (value: string[]) => void; -} - -/** - * Multi-select of admin-owned logging destinations (credential_type=logging) that an - * identity (key / team / org) exports its traces to. The selected names are persisted to - * the identity's logging_exporters column; the proxy unions them across the identity - * chain and fans out. - * - * Assigning logging exporters is proxy-admin only (GET /credentials and the assignment - * gate both reject non-proxy-admins), so this control renders only for a proxy admin. - */ -const LoggingExportersSelect: React.FC = ({ value, onChange }) => { - const { userRole } = useAuthorized(); - const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; - const { data } = useCredentials(isProxyAdmin); - - if (!isProxyAdmin) { - return null; - } - - const options = (data?.credentials ?? []) - .filter((credential) => credential.credential_info?.credential_type === "logging") - .map((credential) => ({ - value: credential.credential_name, - label: credential.credential_info?.host - ? `${credential.credential_name} (${credential.credential_info.host})` - : credential.credential_name, - })); - - return ( -