fix(otel/v2, ui): scope auto_enable by access grants; fix Mode/Scope display

Backend resolver fix (logging_exporter_access.py, litellm_pre_call_utils.py):
  auto_enable=true previously ignored access.teams/orgs entirely — any request
  triggered the destination regardless of its access grants.

  New scoped behavior:
    auto_enable=true + access.global=true   -> fires for all requests
    auto_enable=true + access.teams=[A]     -> fires only for teamA requests
    auto_enable=true + access.orgs=[O]      -> fires only for orgO requests
    auto_enable=true + empty access         -> proxy-wide (backward compat)

  Introduced _has_explicit_access_grants() as the single check point in both
  the request-time resolver and is_destination_visible(). 18 new unit tests;
  all 62 pass. Live verified: 24/24 regression cases with real Anthropic spend.

UI fixes (LoggingCallbacksTable.tsx, types.ts, settings.tsx):
  Mode column:
    auto_enable=true  -> Auto-enabled (orange badge with tooltip)
    auto_enable=false -> Manual assignment
  Scope column (explicit access grants only, no runtime behavior):
    access.global=true -> Global access
    access.teams=[...] -> team: ...
    access.orgs=[...]  -> org: ...
    empty/absent       -> - (regardless of auto_enable)
  Tooltip on Auto-enabled explains proxy-wide backward compat when access
  is empty and guides admins to add explicit grants.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-04 01:54:20 +00:00
parent e68d7f0db7
commit e0d6c85fb3
No known key found for this signature in database
6 changed files with 110 additions and 14 deletions

View file

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

View file

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

View file

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

View file

@ -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 <span className="text-gray-400"></span>;
}
if (scope.global) {
return <Tag color="blue">Global</Tag>;
return <Tag color="blue">Global access</Tag>;
}
const items = [
...scope.teams.map((label) => ({ kind: "team" as const, label })),
@ -97,9 +103,27 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
title: <span className="font-medium text-gray-700">Mode</span>,
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 <span className="text-gray-400"></span>;
// 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 (
<Tooltip title={tooltipTitle}>
<Tag color="orange" style={{ cursor: "help" }}>Auto-enabled</Tag>
</Tooltip>
);
}
return <span className="text-gray-400 text-xs">Manual assignment</span>;
}
// 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.

View file

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

View file

@ -320,6 +320,7 @@ const Settings: React.FC<SettingsPageProps> = ({ 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),
}));