diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 72277cd0be9..53fdbcedaca 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -650,7 +650,14 @@ async def _resolve_logging_exporters( if info is None or info.credential_type != "logging": return False if info.auto_enable: - return True + # auto_enable is scoped by access: if access has explicit grants, + # the request identity must fall within them. Empty access = proxy-wide. + from litellm.proxy.management_endpoints.logging_exporter_access import ( + _has_explicit_access_grants, + ) + if _has_explicit_access_grants(info.access): + return access_grants(info.access, team_ids, org_ids) + return True # no grants = proxy-wide auto if credential.credential_name not in names: return False return access_grants(info.access, team_ids, org_ids) diff --git a/litellm/proxy/management_endpoints/logging_exporter_access.py b/litellm/proxy/management_endpoints/logging_exporter_access.py index f542a10a89e..4514385bb60 100644 --- a/litellm/proxy/management_endpoints/logging_exporter_access.py +++ b/litellm/proxy/management_endpoints/logging_exporter_access.py @@ -68,12 +68,40 @@ def access_grants( return not org_ids.isdisjoint(access.orgs) +def _has_explicit_access_grants(access: Optional[CredentialAccess]) -> bool: + """True when ``access`` contains at least one explicit grant (global, team, or org). + + Used to distinguish "access intentionally left empty" (proxy-wide fallback) from + "access scoped to specific teams or orgs". + """ + if access is None: + return False + return access.global_ or bool(access.teams) or bool(access.orgs) + + def is_destination_visible( info: CredentialInfo, team_ids: frozenset[str], org_ids: frozenset[str], ) -> bool: """Whether a caller admin-scoped to ``team_ids`` / ``org_ids`` may see and assign - this destination: an auto-enabled default, or a grant that reaches their scope. + this destination. + + ``auto_enable`` is scoped by ``access``: + - If ``access`` has explicit grants (global / teams / orgs), the caller must + fall within those grants — even for auto-enabled destinations. + - If ``access`` is empty (no grants at all), the destination is treated as + proxy-wide and is visible to every admin caller. This preserves backward + compatibility for ``auto_enable=True`` destinations created without an + ``access`` block. + + A destination with ``auto_enable=False`` follows the same access check; the + only difference is that ``auto_enable=True`` without any explicit grants is + visible to all admins, while ``auto_enable=False`` without grants is visible + to nobody. """ - return info.auto_enable or access_grants(info.access, team_ids, org_ids) + if _has_explicit_access_grants(info.access): + return access_grants(info.access, team_ids, org_ids) + # No explicit grants: auto_enable=True → proxy-wide (visible to all admins); + # auto_enable=False → invisible (no grants = not reachable by any non-admin). + return info.auto_enable 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 bcb357a0753..8ad30a3b9df 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 @@ -99,12 +99,44 @@ def test_access_grants_not_global_when_false(): assert access_grants(a, frozenset({"t1"}), frozenset({"o1"})) is False -# --- is_destination_visible: auto_enable OR grant -------------------------- +# --- is_destination_visible: auto_enable scoped by access ------------------ +# +# auto_enable=True means "selected automatically" but the scope of that +# automatic selection is controlled by access: +# - explicit grants (global/teams/orgs) → caller must be within the grant +# - empty access (no grants at all) → proxy-wide fallback (visible to all) +# This lets admins create a team-scoped auto-exporter without it leaking to +# every other team on the proxy. -def test_visible_auto_enable_ignores_access(): +def test_visible_auto_enable_empty_access_is_proxy_wide(): + """auto_enable=True with no access grants is proxy-wide: visible to all admins.""" info = CredentialInfo(credential_type="logging", auto_enable=True) assert is_destination_visible(info, frozenset(), frozenset()) is True + assert is_destination_visible(info, frozenset({"any-team"}), frozenset()) is True + assert is_destination_visible(info, frozenset(), frozenset({"any-org"})) is True + + +def test_visible_auto_enable_global_access_is_proxy_wide(): + """auto_enable=True + access.global=True is proxy-wide.""" + info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(global_=True)) + assert is_destination_visible(info, frozenset({"t1"}), frozenset()) is True + assert is_destination_visible(info, frozenset(), frozenset()) is True + + +def test_visible_auto_enable_team_scoped(): + """auto_enable=True + access.teams=[t1] is visible only to t1 admins.""" + info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(teams=["t1"])) + assert is_destination_visible(info, frozenset({"t1"}), frozenset()) is True + assert is_destination_visible(info, frozenset({"t2"}), frozenset()) is False + assert is_destination_visible(info, frozenset(), frozenset()) is False + + +def test_visible_auto_enable_org_scoped(): + """auto_enable=True + access.orgs=[o1] is visible only to o1 admins.""" + info = CredentialInfo(credential_type="logging", auto_enable=True, access=_access(orgs=["o1"])) + assert is_destination_visible(info, frozenset(), frozenset({"o1"})) is True + assert is_destination_visible(info, frozenset(), frozenset({"o2"})) is False def test_visible_delegates_to_access_when_not_auto_enable(): diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index 82555ce8929..b817cd2ade8 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -1,6 +1,6 @@ import { Button } from "@tremor/react"; import type { TableProps } from "antd"; -import { Table, Tag } from "antd"; +import { Table, Tag, Tooltip } from "antd"; import Title from "antd/es/typography/Title"; import React from "react"; import TableIconActionButton from "../../../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; @@ -27,17 +27,23 @@ const isDestination = (record: AlertingObject): boolean => record.credentialName const SCOPE_BADGES_LIMIT = 4; -// Renders the union of identities that route to this destination, with each team/org -// labeled by its alias. Global supersedes everything. Pulls from record.resolvedScope -// (computed at the page level from BOTH directions: destination-side access AND -// identity-side metadata.logging_exporters). +// Renders the explicit access grants for a destination. +// +// The Scope column shows only what is statically configured in credential_info.access. +// It does NOT reflect runtime enablement behavior (auto_enable) — that is the +// Mode column's job. This keeps the two concepts cleanly separated: +// - access.global=true → "Global access" (visible/assignable by all) +// - access.teams=[...] → per-team badges +// - access.orgs=[...] → per-org badges +// - empty/absent access → "—" in all cases, including auto_enable=true const ScopeCell: React.FC<{ record: AlertingObject }> = ({ record }) => { const scope = record.resolvedScope; + if (!scope || (!scope.global && scope.teams.length === 0 && scope.orgs.length === 0)) { return ; } if (scope.global) { - return Global; + return Global access; } const items = [ ...scope.teams.map((label) => ({ kind: "team" as const, label })), @@ -97,9 +103,27 @@ export const LoggingCallbacksTable: React.FC = ({ title: Mode, key: "mode", render: (_: unknown, record: CallbackRow) => { - // Destination rows fan out on every span, so the success/failure split - // does not apply -- only config callbacks carry a mode. - if (isDestination(record)) return ; + // Destination rows show their enablement behaviour: auto_enable=true + // means the destination exports automatically (scoped by access grants); + // false means it only exports when explicitly named in logging_exporters. + if (isDestination(record)) { + if (record.autoEnable === true) { + const access = record.access; + const hasExplicitGrants = + access?.global === true || + (Array.isArray(access?.teams) && access.teams.length > 0) || + (Array.isArray(access?.orgs) && access.orgs.length > 0); + const tooltipTitle = hasExplicitGrants + ? "Exports automatically for all identities within the access scope without requiring explicit assignment." + : "No explicit access grants. Treated as proxy-wide automatic export for backward compatibility. Add access.global=true or access.teams/orgs to scope this destination."; + return ( + + Auto-enabled + + ); + } + return Manual assignment; + } // Backend sends `type` (success | failure); legacy in-memory rows // from add-callback flow set `mode`. Read both so newly-added rows // and server-fetched rows both render correctly. 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 3bf7b09e47c..006b9ebe5c4 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts @@ -14,6 +14,10 @@ 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 diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index f5508ac702b..ebbd622b8ef 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -320,6 +320,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, ? `${backendLabel(c.credential_info?.description)} · ${c.credential_info.host}` : backendLabel(c.credential_info?.description), access: c.credential_info?.access, + autoEnable: c.credential_info?.auto_enable === true, resolvedScope: resolveScope(c.credential_name, c.credential_info?.access), }));