mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-17 23:51:30 +00:00
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:
parent
e68d7f0db7
commit
e0d6c85fb3
6 changed files with 110 additions and 14 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue