feat(otel/v2): store logging-exporter assignments in typed columns, not metadata

Move key/team/org logging-exporter assignments out of the untyped metadata JSON
blob into a typed logging_exporters String[] column on LiteLLM_VerificationToken,
LiteLLM_TeamTable, and LiteLLM_OrganizationTable (plus the Deleted* mirrors),
matching the existing policies/access_group_ids column convention rather than
burying a named-resource list in generic metadata.

The request-time resolver reads each identity level from its own column via
get_key_object / get_team_object / get_org_object. The six write paths (key
generate/update/regenerate, team new/update, org new/update) accept a typed
logging_exporters field, gate it through the shared visibility predicate via a
thin validate_logging_exporter_field adapter (so the assignment validator and its
tests are unchanged), and persist it to the column. Keys persist it like
access_group_ids (a real column), not like policies (which is tucked into
metadata). The typed optional field also removes the metadata removal-via-omission
attack surface: omitting the field means no change.

Round-trip validated against a real Postgres for team and key; regression tests
cover the resolver reading columns and the field adapter's gating.
This commit is contained in:
yucheng-berriai 2026-07-02 00:12:16 -07:00
parent 5b866cdeb0
commit a86e333832
16 changed files with 302 additions and 181 deletions

View file

@ -93,6 +93,21 @@ FORK C - Y7 fan_out/routing: **C2 chosen**. Merge `TenantFanOutSpanProcessor` in
- Y6 full carrier removal (request-data -> ContextVar for the gen-AI span): NOT done blind. Evidence against a naive removal: the gen-AI logger binds the tenant tracer from `standard_callback_dynamic_params.otel_destinations` (`logger.py:237`, and the deferred close path `:344/:364`). The tracer is bound at `log_pre_api_call` (request task, ContextVar present) for the mainline, but `streaming_handler.py:1668` closes via `asyncio.run(...)`, a fresh event-loop context where the server-only ContextVar may not survive, whereas the kwargs-borne `dynamic_params` always does. So moving the gen-AI path to the ContextVar risks a streaming tenant-routing regression that cannot be validated without a live streaming proxy. Recommendation: keep `dynamic_params` as the robust carrier, rely on the now-hardened wipe for the trust boundary (Y3 proven), and treat ContextVar unification as a follow-up gated on live streaming validation. Open for the maintainer's call.
## 7b. A2 progress (as built, uncommitted WIP)
Environment self-unblocked: installed + generated prisma-client-py 0.11.0 (was an ungenerated namespace stub), stood up a throwaway Postgres (docker `litellm-pg`, port 5599), `prisma db push` applied the schema.
Done and DB-validated:
- `logging_exporters String[] @default([])` added to `LiteLLM_TeamTable`, `LiteLLM_OrganizationTable`, `LiteLLM_VerificationToken`, and the `Deleted*` mirrors, in all three synced `schema.prisma` copies. Round-trip proven against real Postgres: `team.logging_exporters` and `key.logging_exporters` persist and read back through the generated client.
- Pydantic mirrors carry the field (`litellm/models/team.py`, `organization.py`, `verification_token.py`); it propagates to `LiteLLM_VerificationTokenView` / `UserAPIKeyAuth`. `get_key_object`/`get_org_object`/`get_team_object` hydrate via `**model_dump()`, so the column flows in automatically.
- Resolver read path (`_union_logging_exporter_names`) migrated from `metadata.logging_exporters` to the `.logging_exporters` column, reading each level from its own object (key/team/org), DB-required.
Remaining for a complete, consistent A2 (this is why the tree currently has 5 red resolver tests, since a half-migration is intentionally not committed):
- Write path across 6 endpoint sites (key generate/update/regenerate, team new/update, org new/update), each with bespoke persistence (team `/new` auto-persists via `data.json()` -> mirror; keys map fields explicitly). Recommended low-churn approach: add `logging_exporters: Optional[list[str]]` to the request models, feed the existing `validate_logging_exporter_assignment` a synthesized `{"logging_exporters": data.logging_exporters}` dict (leaving the validator and its 38 tests unchanged), and persist the field to the column.
- Update the 5 resolver tests to the column model (mock `get_team_object`; set `prisma_client`).
- Add the proxy-extras `migration.sql` (`ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[]` on the four tables, per the `policies` precedent).
- UI: send `logging_exporters` as a top-level field, read from the column.
## 8. Test plan (maps to the 6 required areas)
1. Visibility: proxy-admin sees per policy; team/org-admin cannot see out-of-scope destinations from `GET /credentials`; global/scoped/auto_enable follow policy.

View file

@ -0,0 +1,14 @@
-- 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_VerificationToken" ADD COLUMN IF NOT EXISTS "logging_exporters" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "LiteLLM_DeletedVerificationToken" 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[];

View file

@ -87,6 +87,7 @@ 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?
@ -142,6 +143,7 @@ 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
@ -210,6 +212,7 @@ 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)
@ -415,6 +418,7 @@ model LiteLLM_VerificationToken {
allowed_routes String[] @default([])
policies String[] @default([])
access_group_ids String[] @default([])
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names)
model_spend Json @default("{}")
model_max_budget Json @default("{}")
budget_id String?
@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken {
allowed_routes String[] @default([])
policies String[] @default([])
access_group_ids String[] @default([])
logging_exporters String[] @default([])
model_spend Json @default("{}")
model_max_budget Json @default("{}")
router_settings Json? @default("{}")

View file

@ -22,6 +22,7 @@ class LiteLLM_OrganizationTable(LiteLLMPydanticObjectBase):
spend: float = 0.0
metadata: Optional[dict] = None
models: List[str] = []
logging_exporters: Optional[List[str]] = None
model_spend: Optional[dict] = {}
created_by: str
updated_by: str

View file

@ -92,6 +92,7 @@ class LiteLLM_TeamTable(TeamBase):
model_spend: Optional[dict] = {}
model_max_budget: Optional[dict] = {}
policies: Optional[List[str]] = None
logging_exporters: Optional[List[str]] = None
allow_team_guardrail_config: Optional[bool] = False
litellm_model_table: Optional[LiteLLM_ModelTable] = None
object_permission: Optional[LiteLLM_ObjectPermissionTable] = None

View file

@ -52,6 +52,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
object_permission_id: Optional[str] = None
object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
access_group_ids: Optional[List[str]] = None
logging_exporters: Optional[List[str]] = None
rotation_count: Optional[int] = 0
auto_rotate: Optional[bool] = False
rotation_interval: Optional[str] = None

View file

@ -1048,6 +1048,7 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
mcp_rpm_limit: Optional[Dict[str, int]] = None
guardrails: Optional[List[str]] = None
policies: Optional[List[str]] = None
logging_exporters: Optional[List[str]] = None
prompts: Optional[List[str]] = None
blocked: Optional[bool] = None
aliases: Optional[dict] = {}
@ -1705,6 +1706,7 @@ class NewTeamRequest(TeamBase):
tags: Optional[list] = None
guardrails: Optional[List[str]] = None
policies: Optional[List[str]] = None
logging_exporters: Optional[List[str]] = None
prompts: Optional[List[str]] = None
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
allowed_passthrough_routes: Optional[list] = None
@ -1771,6 +1773,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
model_aliases: Optional[dict] = None
guardrails: Optional[List[str]] = None
policies: Optional[List[str]] = None
logging_exporters: Optional[List[str]] = None
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
disable_global_guardrails: Optional[bool] = None
team_member_budget: Optional[float] = None
@ -1914,6 +1917,7 @@ class NewOrganizationRequest(LiteLLM_BudgetTable):
models: List = []
budget_id: Optional[str] = None
metadata: Optional[dict] = None
logging_exporters: Optional[List[str]] = None
model_rpm_limit: Optional[Dict[str, int]] = None
model_tpm_limit: Optional[Dict[str, int]] = None
@ -2598,6 +2602,7 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable):
spend: Optional[float] = None
metadata: Optional[dict] = None
models: Optional[List[str]] = None
logging_exporters: Optional[List[str]] = None
updated_by: Optional[str] = None
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
model_tpm_limit: Optional[Dict[str, int]] = None

View file

@ -544,57 +544,73 @@ async def _effective_org_id(user_api_key_dict: UserAPIKeyAuth) -> Optional[str]:
async def _union_logging_exporter_names(user_api_key_dict: UserAPIKeyAuth, org_id: Optional[str]) -> set:
"""The union of admin-assigned exporter names across the request's identity chain.
Resolves each level from its OWN record: the key's ``metadata`` is shadowed by the
team's on the auth object, so it is fetched fresh via ``get_key_object``; the org's
metadata is fetched via ``get_org_object`` using the effective ``org_id`` (token org
or team fallback); the team's is already its own on ``team_metadata``. Internal-user
is intentionally not a routing dimension. The lists are admin-owned; the request
never supplies them. Degrades to team-only when no DB is connected (SDK mode).
Each level is read from its own ``logging_exporters`` column: the key via
``get_key_object`` (the auth object's fields are the team's shadow, so it is
fetched fresh), the team via ``get_team_object``, the org via ``get_org_object``
on the effective ``org_id`` (token org or team fallback). 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_key_object, get_org_object
names: set = set()
def _add(metadata: Any) -> None:
if not isinstance(metadata, dict):
return
assigned = metadata.get("logging_exporters")
if isinstance(assigned, list):
names.update(str(name) for name in assigned)
from litellm.proxy.auth.auth_checks import (
get_key_object,
get_org_object,
get_team_object,
)
prisma_client = proxy_server.prisma_client
if prisma_client is None:
return set()
cache = proxy_server.user_api_key_cache
span = getattr(user_api_key_dict, "parent_otel_span", None)
names: set = set()
# KEY: the key's own metadata (the auth object's .metadata is the team's shadow).
if user_api_key_dict.token and prisma_client is not None:
def _add(obj: object) -> None:
assigned = getattr(obj, "logging_exporters", None)
if isinstance(assigned, (list, tuple)):
names.update(str(name) for name in assigned)
if user_api_key_dict.token:
try:
key_obj = await get_key_object(
hashed_token=user_api_key_dict.token,
prisma_client=prisma_client,
user_api_key_cache=cache,
parent_otel_span=span,
proxy_logging_obj=proxy_server.proxy_logging_obj,
_add(
await get_key_object(
hashed_token=user_api_key_dict.token,
prisma_client=prisma_client,
user_api_key_cache=cache,
parent_otel_span=span,
proxy_logging_obj=proxy_server.proxy_logging_obj,
)
)
_add(key_obj.metadata)
except Exception: # noqa: BLE001
pass
# TEAM: team_metadata is already the team's own.
_add(user_api_key_dict.team_metadata)
# ORG: the org's own metadata (central catch-all).
if org_id and prisma_client is not None:
if user_api_key_dict.team_id:
try:
org_obj = await 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,
_add(
await 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,
)
)
except Exception: # noqa: BLE001
pass
if org_id:
try:
_add(
await 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,
)
)
_add(getattr(org_obj, "metadata", None))
except Exception: # noqa: BLE001
pass

View file

@ -1405,8 +1405,7 @@ async def generate_key_fn(
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
LOGGING_EXPORTERS_KEY,
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
from litellm.proxy.proxy_server import (
prisma_client,
@ -1495,14 +1494,13 @@ async def generate_key_fn(
route=KeyManagementRoutes.KEY_GENERATE,
)
# Team-admin of the key's team or org-admin of that team's org may
# write metadata.logging_exporters on team-owned keys. Personal keys
# (no team_table) stay proxy-admin only. Skip the role lookup when
# the field isn't in the payload to keep /key/generate cheap for the
# common case.
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
validate_logging_exporter_assignment(
data.metadata,
# Team-admin of the key's team or org-admin of that team's org may assign
# logging_exporters on team-owned keys. Personal keys (no team_table) stay
# proxy-admin only. Skip the role lookup when the field isn't in the payload
# to keep /key/generate cheap for the common case.
if data.logging_exporters is not None:
validate_logging_exporter_field(
data.logging_exporters,
user_api_key_dict,
caller_is_team_admin=(
team_table is not None
@ -1700,13 +1698,12 @@ async def generate_service_account_key_fn(
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
LOGGING_EXPORTERS_KEY,
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
validate_logging_exporter_assignment(
data.metadata,
if data.logging_exporters is not None:
validate_logging_exporter_field(
data.logging_exporters,
user_api_key_dict,
caller_is_team_admin=(
team_table is not None and _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_table)
@ -2457,7 +2454,7 @@ async def update_key_fn( # noqa: C901
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
from litellm.proxy.proxy_server import (
llm_router,
@ -2485,14 +2482,11 @@ async def update_key_fn( # noqa: C901
prisma_client=prisma_client,
)
# logging-exporters validation runs once the key's team is known so
# a team-admin or org-admin of that team can attach destinations.
# The validator no-ops when the effective value doesn't change; pass
# the stored metadata so removal-via-omission gates too (Veria F4).
if isinstance(data.metadata, dict):
_existing_key_metadata = (
existing_key_row.metadata if isinstance(getattr(existing_key_row, "metadata", None), dict) else None
)
# logging-exporters validation runs once the key's team is known so a
# team-admin or org-admin of that team can attach destinations. The
# validator no-ops when the effective value doesn't change; pass the
# stored column value so a non-admin cannot clear an admin-assigned one.
if data.logging_exporters is not None:
_key_team_id = getattr(existing_key_row, "team_id", None)
_key_team = None
if _key_team_id is not None:
@ -2506,8 +2500,8 @@ async def update_key_fn( # noqa: C901
)
except HTTPException:
_key_team = None
validate_logging_exporter_assignment(
data.metadata,
validate_logging_exporter_field(
data.logging_exporters,
user_api_key_dict,
caller_is_team_admin=(
_key_team is not None
@ -2517,7 +2511,7 @@ async def update_key_fn( # noqa: C901
_key_team is not None
and await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=_key_team)
),
existing_metadata=_existing_key_metadata,
existing_exporters=getattr(existing_key_row, "logging_exporters", None),
scope_team_id=getattr(_key_team, "team_id", None),
scope_org_id=getattr(_key_team, "organization_id", None),
)
@ -3507,6 +3501,7 @@ async def generate_key_helper_fn(
rotation_interval: Optional[str] = None,
router_settings: Optional[dict] = None,
access_group_ids: Optional[list] = None,
logging_exporters: Optional[list] = None, # admin-owned OTEL destinations (credential names)
budget_limits: Optional[list] = None, # multiple concurrent budget windows
):
from litellm.proxy.proxy_server import premium_user, prisma_client
@ -3638,6 +3633,7 @@ async def generate_key_helper_fn(
"object_permission_id": object_permission_id,
"router_settings": router_settings_json,
"access_group_ids": access_group_ids or [],
"logging_exporters": logging_exporters or [],
}
# Add rotation fields if auto_rotate is enabled
@ -4580,26 +4576,22 @@ async def regenerate_key_fn( # noqa: C901
)
# logging_exporters gate on regenerate matches /key/generate and
# /key/update. Without this, a key owner could set
# metadata.logging_exporters on /key/{id}/regenerate and route
# future traces to a destination they aren't allowed to assign
# (Veria F3). The validator no-ops when the effective value
# doesn't change; pass stored metadata so removal-via-omission
# gates too (Veria F4).
if data is not None and isinstance(data.metadata, dict):
# /key/update. Without this, a key owner could set logging_exporters on
# /key/{id}/regenerate and route future traces to a destination they
# aren't allowed to assign (Veria F3). The validator no-ops when the
# effective value doesn't change; pass the stored column value so a
# non-admin cannot clear an admin-assigned one.
if data is not None and data.logging_exporters is not None:
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
_regen_existing_metadata = (
_key_in_db.metadata if isinstance(getattr(_key_in_db, "metadata", None), dict) else None
)
validate_logging_exporter_assignment(
data.metadata,
validate_logging_exporter_field(
data.logging_exporters,
user_api_key_dict,
caller_is_team_admin=(
regenerate_team_table is not None
@ -4615,7 +4607,7 @@ async def regenerate_key_fn( # noqa: C901
team_obj=regenerate_team_table,
)
),
existing_metadata=_regen_existing_metadata,
existing_exporters=getattr(_key_in_db, "logging_exporters", None),
scope_team_id=getattr(regenerate_team_table, "team_id", None),
scope_org_id=getattr(regenerate_team_table, "organization_id", None),
)

View file

@ -172,6 +172,38 @@ def _exporter_value_changes(
return requested_metadata.get(LOGGING_EXPORTERS_KEY) != existing
def validate_logging_exporter_field(
requested_exporters: Optional[list],
user_api_key_dict: UserAPIKeyAuth,
*,
caller_is_team_admin: bool = False,
caller_is_org_admin: bool = False,
existing_exporters: Optional[list] = None,
scope_team_id: Optional[str] = None,
scope_org_id: Optional[str] = None,
) -> None:
"""Authorize a typed ``logging_exporters`` write (the column-backed field).
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,
caller_is_team_admin=caller_is_team_admin,
caller_is_org_admin=caller_is_org_admin,
existing_metadata=existing_metadata,
scope_team_id=scope_team_id,
scope_org_id=scope_org_id,
)
def validate_logging_exporter_assignment(
metadata: Optional[dict],
user_api_key_dict: UserAPIKeyAuth,

View file

@ -199,10 +199,10 @@ async def new_organization(
```
"""
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
validate_logging_exporter_assignment(getattr(data, "metadata", None), user_api_key_dict)
validate_logging_exporter_field(getattr(data, "logging_exporters", None), user_api_key_dict)
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
@ -459,10 +459,10 @@ async def update_organization(
data = LiteLLM_OrganizationTableUpdate(**raw_data_with_flat_budget_fields)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
validate_logging_exporter_assignment(getattr(data, "metadata", None), user_api_key_dict)
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):

View file

@ -992,8 +992,7 @@ async def new_team(
_is_user_org_admin_for_org_id,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
LOGGING_EXPORTERS_KEY,
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
@ -1010,9 +1009,9 @@ async def new_team(
# the destination org may assign logging exporters at creation time.
# Skip the org-admin lookup entirely when the field isn't being
# written, to avoid hitting the cache for unrelated /team/new calls.
if isinstance(data.metadata, dict) and LOGGING_EXPORTERS_KEY in data.metadata:
validate_logging_exporter_assignment(
data.metadata,
if data.logging_exporters is not None:
validate_logging_exporter_field(
data.logging_exporters,
user_api_key_dict,
caller_is_org_admin=await _is_user_org_admin_for_org_id(
user_api_key_dict=user_api_key_dict,
@ -1663,7 +1662,7 @@ async def update_team(
_is_user_org_admin_for_team,
)
from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
@ -1725,18 +1724,15 @@ async def update_team(
# this path. Pass only the org-admin flag so the validator can't
# silently grant team-admins if the route gate is ever widened. The
# validator no-ops when the effective value doesn't change; pass the
# stored metadata so removal-via-omission gates too (Veria F4).
if isinstance(data.metadata, dict):
existing_team_metadata = (
existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else None
)
validate_logging_exporter_assignment(
data.metadata,
# stored column value so a non-admin cannot clear an admin-assigned one.
if data.logging_exporters is not None:
validate_logging_exporter_field(
data.logging_exporters,
user_api_key_dict,
caller_is_org_admin=await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_for_auth
),
existing_metadata=existing_team_metadata,
existing_exporters=getattr(existing_team_row, "logging_exporters", None),
scope_team_id=getattr(team_for_auth, "team_id", None),
scope_org_id=getattr(team_for_auth, "organization_id", None),
)

View file

@ -87,6 +87,7 @@ 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?
@ -142,6 +143,7 @@ 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
@ -210,6 +212,7 @@ 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)
@ -415,6 +418,7 @@ model LiteLLM_VerificationToken {
allowed_routes String[] @default([])
policies String[] @default([])
access_group_ids String[] @default([])
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names)
model_spend Json @default("{}")
model_max_budget Json @default("{}")
budget_id String?
@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken {
allowed_routes String[] @default([])
policies String[] @default([])
access_group_ids String[] @default([])
logging_exporters String[] @default([])
model_spend Json @default("{}")
model_max_budget Json @default("{}")
router_settings Json? @default("{}")

View file

@ -87,6 +87,7 @@ 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?
@ -142,6 +143,7 @@ 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
@ -210,6 +212,7 @@ 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)
@ -415,6 +418,7 @@ model LiteLLM_VerificationToken {
allowed_routes String[] @default([])
policies String[] @default([])
access_group_ids String[] @default([])
logging_exporters String[] @default([]) // admin-owned OTEL trace destinations assigned to this key (credential names)
model_spend Json @default("{}")
model_max_budget Json @default("{}")
budget_id String?
@ -508,6 +512,7 @@ model LiteLLM_DeletedVerificationToken {
allowed_routes String[] @default([])
policies String[] @default([])
access_group_ids String[] @default([])
logging_exporters String[] @default([])
model_spend Json @default("{}")
model_max_budget Json @default("{}")
router_settings Json? @default("{}")

View file

@ -22,6 +22,7 @@ from litellm.proxy.management_endpoints.logging_exporter_validation import (
is_admin_gated_credential_info,
validate_credential_access,
validate_logging_exporter_assignment,
validate_logging_exporter_field,
)
@ -393,3 +394,60 @@ 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_without_flags_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_scoped_rejection_for_out_of_scope_team(_registry):
"""arize-ds is granted to ds-team; a team admin writing in another team's scope
cannot name it, exactly as the metadata path gated it."""
with pytest.raises(HTTPException) as exc:
validate_logging_exporter_field(
["arize-ds"],
_non_admin(),
caller_is_team_admin=True,
scope_team_id="platform-team",
)
assert exc.value.status_code == 403
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"],
)
def test_field_admin_can_assign_out_of_scope(_registry):
"""Proxy admin skips the scope check (parity with the metadata path)."""
validate_logging_exporter_field(["arize-ds"], _admin(), scope_team_id="platform-team")

View file

@ -4845,24 +4845,46 @@ def _seeded_logging_credentials():
litellm.credential_list = original
def _auth(team_exporters=None, token=None, org_id=None, team_id=None):
return UserAPIKeyAuth(
api_key="hashed-key",
token=token,
org_id=org_id,
team_id=team_id,
team_metadata=({"logging_exporters": team_exporters} if team_exporters else {}),
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.
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.
"""
from types import SimpleNamespace
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.auth import auth_checks
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)))
)
@pytest.mark.asyncio
async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials):
# team_metadata is the team's own (not shadowed); resolves without a DB fetch.
async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials, monkeypatch):
# team assignment lives on the team's logging_exporters column.
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
destinations, backends = await _resolve_logging_exporters(
_auth(team_exporters=["langfuse-eu"])
)
_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"
}
@ -4873,35 +4895,13 @@ async def test_resolve_logging_exporters_team_level(_seeded_logging_credentials)
async def test_resolve_logging_exporters_unions_key_team_org(
_seeded_logging_credentials, monkeypatch
):
# key + org are read from their OWN records (the key's .metadata is team-shadowed),
# team from team_metadata. All three union, deduped.
from types import SimpleNamespace
import litellm.proxy.proxy_server as proxy_server
from litellm.proxy.auth import auth_checks
# key, team, and org are each read from their OWN logging_exporters column.
# All three union, deduped.
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(
auth_checks,
"get_key_object",
AsyncMock(
return_value=SimpleNamespace(metadata={"logging_exporters": ["arize-prod"]})
),
)
monkeypatch.setattr(
auth_checks,
"get_org_object",
AsyncMock(
return_value=SimpleNamespace(
metadata={"logging_exporters": ["langfuse-eu"]}
)
),
)
_patch_identity(monkeypatch, key=["arize-prod"], team=["langfuse-eu"], org=["langfuse-eu"])
destinations, backends = await _resolve_logging_exporters(
_auth(team_exporters=["langfuse-eu"], token="hashed-key", org_id="org-1")
)
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)
@ -4912,13 +4912,12 @@ async def test_resolve_logging_exporters_unions_key_team_org(
@pytest.mark.asyncio
async def test_resolve_logging_exporters_carries_arize_project(
_seeded_logging_credentials,
_seeded_logging_credentials, monkeypatch
):
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
destinations, _ = await _resolve_logging_exporters(
_auth(team_exporters=["arize-prod"])
)
_patch_identity(monkeypatch, team=["arize-prod"])
destinations, _ = await _resolve_logging_exporters(_auth())
assert destinations == [
{
@ -4945,20 +4944,19 @@ async def test_resolve_logging_exporters_empty_without_assignment(
@pytest.mark.asyncio
async def test_resolve_logging_exporters_skips_unknown_and_provider_creds(
_seeded_logging_credentials,
_seeded_logging_credentials, monkeypatch
):
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
# unknown name + a provider credential (not credential_type=logging) -> nothing
destinations, backends = await _resolve_logging_exporters(
_auth(team_exporters=["does-not-exist", "openai-key"])
)
_patch_identity(monkeypatch, team=["does-not-exist", "openai-key"])
destinations, backends = await _resolve_logging_exporters(_auth())
assert destinations == [] and backends == []
@pytest.mark.asyncio
async def test_apply_admin_logging_exporters_stamps_and_activates(
_seeded_logging_credentials,
_seeded_logging_credentials, monkeypatch
):
from litellm.integrations.otel.plumbing.context import (
_request_destinations,
@ -4966,12 +4964,11 @@ 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"])
token = _request_destinations.set(())
data: dict = {}
try:
await _apply_admin_logging_exporters(
data, _auth(team_exporters=["langfuse-eu"])
)
await _apply_admin_logging_exporters(data, _auth())
# destinations live under litellm_metadata so the body does not leak an
# unknown top-level key to the provider; the top-level key stays absent
@ -5074,7 +5071,7 @@ async def test_client_cannot_control_otel_destinations(_seeded_logging_credentia
@pytest.mark.asyncio
async def test_apply_admin_logging_exporters_registers_on_failure(
_seeded_logging_credentials,
_seeded_logging_credentials, monkeypatch
):
"""An admin-owned destination must capture a FAILED upstream call, not only a
successful one.
@ -5088,14 +5085,13 @@ 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"])
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(team_exporters=["langfuse-eu", "arize-prod"])
)
await _apply_admin_logging_exporters(data, _auth())
for callback_list in ("success_callback", "failure_callback"):
registered = data[callback_list]
assert "langfuse_otel" in registered
@ -5176,31 +5172,29 @@ async def test_resolve_grant_does_not_auto_enable(
@pytest.mark.asyncio
async def test_resolve_name_with_grant_enables(
_seeded_logging_credentials_with_access,
_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
destinations, _ = await _resolve_logging_exporters(
_auth(team_id="team-eu", team_exporters=["langfuse-eu"])
)
_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,
_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
destinations, _ = await _resolve_logging_exporters(
_auth(team_id="team-other", team_exporters=["langfuse-eu"])
)
_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}
@ -5248,11 +5242,7 @@ async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch)
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 types import SimpleNamespace
import litellm.proxy.proxy_server as proxy_server
from litellm.models.credentials import CredentialItem
from litellm.proxy.auth import auth_checks
from litellm.proxy.litellm_pre_call_utils import _resolve_logging_exporters
original = litellm.credential_list
@ -5267,26 +5257,11 @@ async def test_resolve_org_scoped_via_team_when_token_has_no_org_id(monkeypatch)
},
),
]
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(metadata={}))
)
monkeypatch.setattr(
auth_checks,
"get_org_object",
AsyncMock(return_value=SimpleNamespace(metadata={})),
)
# the key's token has no org_id; the team it belongs to is in org-7.
monkeypatch.setattr(
auth_checks,
"get_team_object",
AsyncMock(return_value=SimpleNamespace(organization_id="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", team_exporters=["arize-org"])
)
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