chore(otel/v2): sweep remaining tenant-surface leftovers after admin-only refactor

Full-diff audit against the admin-only design surfaced leftovers in three layers.

Backend: drop dead code the refactor stranded (is_admin_gated_credential_info,
is_destination_visible, the CredentialInfo decider-era fields, the unused
LLMCallEvent.dynamic_params carrier, the _is_user_org_admin_for_org_id extraction)
and correct every comment/docstring still describing the removed team-admin
self-service or scoped-read designs.

UI: gate the Logging Exporters form rows behind a shared proxy-admin-only
LoggingExportersFormItem so non-admin forms no longer render an orphaned label;
skip the GET /credentials fetch for roles it would 403 (team/org/key views); make
the callbacks table read-only for the admin viewer (no Add/Edit/Delete actions
that would 401); reword access tooltips to routing-scope semantics; regenerate
schema.d.ts from the corrected endpoint docstrings.

Tests: flip the credential-migration non-admin expectation to the route-gate 401,
port the visibility tests to access_grants, drop tests of the removed helpers,
and update copy assertions and stale rationale to the admin-only contract.
This commit is contained in:
Yucheng Zhu 2026-07-28 10:35:44 -07:00
parent e5760dc265
commit 53d745a4b6
27 changed files with 207 additions and 259 deletions

View file

@ -194,9 +194,6 @@ class LLMCallEvent:
# at ``pre_call``, or when the call closed before any payload materialized (so
# there is nothing to stamp on the span).
payload: "StandardLoggingPayload | None"
# The ``standard_callback_dynamic_params`` routing the call to a per-tenant
# tracer (its own exporter/endpoint), or ``None`` when the call isn't scoped.
dynamic_params: Any
# The admin-resolved OTLP destinations (endpoint + auth headers) for this call's
# identity chain, fanned out to. Empty when none are assigned. Read from the
# server-only request ContextVar the proxy anchors at auth time, so it is never
@ -217,11 +214,9 @@ class LLMCallEvent:
payload = cast("StandardLoggingPayload", raw_payload) if raw_payload else None
operation = resolve_operation(as_str(kwargs.get("call_type")))
model = as_str(kwargs.get("model")) or ""
dynamic_params = kwargs.get("standard_callback_dynamic_params")
return cls(
call_id=_call_id(payload, kwargs),
payload=payload,
dynamic_params=dynamic_params,
otel_destinations=request_destinations(),
is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)),
provisional_span_name=f"{operation.value} {model}".strip(),

View file

@ -32,10 +32,10 @@ class CreateCredentialItem(CredentialBase):
class UpdateCredentialItem(BaseModel):
"""PATCH body for ``/credentials/{name}``.
Both ``credential_values`` and ``credential_info`` are optional so a caller
can patch one without sending the other (team-admins patching access without
knowing the upstream secrets; proxy admins rotating values without touching
access). ``credential_name`` is optional because most patches don't rename.
Both ``credential_values`` and ``credential_info`` are optional so the proxy
admin can patch one without sending the other (rotating values without
touching access, or adjusting access without re-sending secrets).
``credential_name`` is optional because most patches don't rename.
"""
credential_name: str | None = None
@ -59,19 +59,17 @@ class CredentialAccess(BaseModel):
class CredentialInfo(BaseModel):
"""Typed shape of ``credential_info`` for the access-control decider.
"""Typed shape of ``credential_info`` as read by the request-time resolver.
Existing stored credentials carry arbitrary extra fields (e.g.
``custom_llm_provider``); ``extra="allow"`` preserves them. The decider
inspects ``model_fields_set`` to learn which fields the caller actually
patched, which is what Pydantic gives us natively without dict-key spelunking.
``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.
"""
model_config = ConfigDict(extra="allow")
credential_type: str | None = None
description: str | None = None
host: str | None = None
endpoint: str | None = None
access: CredentialAccess | None = None
auto_enable: bool = False

View file

@ -32,7 +32,7 @@ def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={"error": "Only the proxy admin can manage logging credentials"},
detail={"error": "Only the proxy admin can manage credentials"},
)
@ -271,8 +271,6 @@ async def delete_credential(
"""
from litellm.proxy.proxy_server import prisma_client
# DELETE stays proxy-admin only. The route gate lets team-admins reach
# /credentials/{name} for PATCH; reject any DELETE that isn't proxy-admin.
_require_proxy_admin(user_api_key_dict)
try:
@ -334,14 +332,12 @@ def update_db_credential(
def _merge_credential_info(into: dict, patch: dict) -> None:
"""Merge ``patch`` into ``into`` in place, with surgical access subfields.
A prior top-level dict.update let a patch like ``{access: {teams: [...]}}``
A top-level dict.update would let a patch like ``{access: {teams: [...]}}``
replace the entire stored ``access`` object, wiping ``access.global=true``
and ``access.orgs`` entries that the decider intentionally protected by
refusing to allow them in the patch (Veria F1: scope tampering). Now
``access`` is merged subfield-by-subfield, so a non-admin patch carrying
only ``access.teams`` keeps existing ``access.global`` / ``access.orgs``
intact. The DB write and the in-memory cache sync both call this so the
two stores can't drift.
and ``access.orgs`` entries the caller never mentioned. ``access`` is merged
subfield-by-subfield instead, so a partial patch keeps the untouched
subfields intact. The DB write and the in-memory cache sync both call this
so the two stores can't drift.
"""
patch_copy = dict(patch)
patch_access = patch_copy.pop("access", None)

View file

@ -111,13 +111,15 @@ def _is_user_team_admin(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_Tea
return False
async def _is_user_org_admin_for_org_id(user_api_key_dict: UserAPIKeyAuth, organization_id: str | None) -> bool:
"""Check if the caller has the ORG_ADMIN role in the given organization.
Returns False when ``organization_id`` is falsy or the caller has no user_id,
so the caller can pass an optional org_id directly without branching.
async def _is_user_org_admin_for_team(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
"""
if not organization_id or not user_api_key_dict.user_id:
Check if user is an org admin for the team's organization.
Returns True if:
- The team belongs to an organization, AND
- The user has org_admin role in that organization
"""
if not team_obj.organization_id or not user_api_key_dict.user_id:
return False
from litellm.proxy.auth.auth_checks import get_user_object
@ -137,18 +139,11 @@ async def _is_user_org_admin_for_org_id(user_api_key_dict: UserAPIKeyAuth, organ
if caller_user is None:
return False
return any(
m.organization_id == organization_id and m.user_role == LitellmUserRoles.ORG_ADMIN.value
for m in (caller_user.organization_memberships or [])
)
for m in caller_user.organization_memberships or []:
if m.organization_id == team_obj.organization_id and m.user_role == LitellmUserRoles.ORG_ADMIN.value:
return True
async def _is_user_org_admin_for_team(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
"""Check if user is an org admin for the team's organization."""
return await _is_user_org_admin_for_org_id(
user_api_key_dict=user_api_key_dict,
organization_id=team_obj.organization_id,
)
return False
def _team_member_has_permission(

View file

@ -1,18 +1,14 @@
"""Shared visibility predicate for admin-owned logging destinations.
"""Request-time routing predicate for admin-owned logging destinations.
``credential_info.access`` answers "who may see and assign this destination". It
is visibility, decoupled from enablement (a named assignment plus the explicit
``auto_enable`` default-on flag). One predicate serves three callers so "visible"
means the same thing everywhere: the ``GET /credentials`` list filter, the
assignment validator, and the request-time resolver. When these disagree a
non-admin can be shown, or route traffic to, a destination it should never see;
keeping the check in one place is what prevents that.
``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.
``access_grants`` is the primitive: does this ``access`` reach a caller whose
admin scope is the given set of team ids and org ids. Single-identity callers
(the resolver, the per-write assignment gate) pass a one-element scope built with
``identity_scope``; the list endpoint, whose caller may administer several teams
and orgs, passes the full scope.
``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 pydantic import ValidationError
@ -25,8 +21,8 @@ def parse_credential_info(raw: object) -> CredentialInfo | None:
absent or malformed.
Callers fail closed on ``None``: a destination whose stored ``access`` cannot be
parsed (a legacy shape the strict read model rejects) is treated as invisible
rather than granted to everyone.
parsed (a legacy shape the strict read model rejects) is treated as granted to
no one rather than granted to everyone.
"""
if not isinstance(raw, dict):
return None
@ -37,8 +33,8 @@ def parse_credential_info(raw: object) -> CredentialInfo | None:
def identity_scope(team_id: str | None, org_id: str | None) -> tuple[frozenset[str], frozenset[str]]:
"""A single request identity's admin scope as ``(team_ids, org_ids)`` for
``access_grants`` / ``is_destination_visible``."""
"""A single request identity's scope as ``(team_ids, org_ids)`` for
``access_grants``."""
return (
frozenset({team_id}) if team_id else frozenset(),
frozenset({org_id}) if org_id else frozenset(),
@ -50,11 +46,11 @@ def access_grants(
team_ids: frozenset[str],
org_ids: frozenset[str],
) -> bool:
"""Whether ``access`` makes a destination visible to a caller admin-scoped to
"""Whether ``access`` grants a destination to an identity scoped to
``team_ids`` / ``org_ids``.
``global`` reaches everyone; otherwise one of the caller's admin teams or orgs
must be granted. A missing ``access`` grants no one (fail closed): visibility is
``global`` reaches everyone; otherwise one of the identity's teams or orgs
must be granted. A missing ``access`` grants no one (fail closed): routing is
an explicit admin grant, never the accident of an absent field.
"""
if access is None:
@ -64,18 +60,3 @@ def access_grants(
if not team_ids.isdisjoint(access.teams):
return True
return not org_ids.isdisjoint(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.
Visibility is decided entirely by ``access``: empty access grants no one, so an
empty-access destination is invisible regardless of ``auto_enable``. Proxy-wide
visibility must be requested explicitly with ``access.global = true``.
"""
return access_grants(info.access, team_ids, org_ids)

View file

@ -15,17 +15,6 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
LOGGING_EXPORTERS_KEY = "logging_exporters"
def is_admin_gated_credential_info(credential_info: dict | None) -> bool:
"""Whether a credential write must be proxy-admin only.
True when the credential is a logging destination or carries an ``access`` grant,
since both control where other tenants' traces are exported.
"""
if not isinstance(credential_info, dict):
return False
return credential_info.get("credential_type") == "logging" or "access" in credential_info
def validate_credential_access(credential_info: dict | None) -> None:
"""Validate ``credential_info.access`` shape when the write sets one.

View file

@ -1019,10 +1019,8 @@ async def new_team(
user_api_key_cache,
)
# New team has no admins yet, so only proxy admin or an org admin of
# 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.
# logging_exporters is proxy-admin only. Skip the check when the field
# isn't being written so unrelated /team/new calls stay cheap.
if data.logging_exporters is not None:
validate_logging_exporter_field(data.logging_exporters, user_api_key_dict)

View file

@ -50,12 +50,9 @@ async def test_migrate_encryption_check_requires_admin(proxy_client, scratch):
async def test_migrate_encryption_requires_admin(proxy_client, scratch):
"""A non-admin key cannot trigger the migration; it is rejected before any write.
``/credentials/{credential_name}`` is a self-managed route (team admins reach
``PATCH /credentials/{name}`` to grant their own team a logging destination), so
the single-segment ``/credentials/migrate-encryption`` path matches that pattern
and clears the route-level gate. The migration endpoint then rejects the
non-admin on its first line via ``_require_proxy_admin`` (403), ahead of any DB
read or write. Either way the non-admin never triggers the migration.
No ``/credentials`` route is self-managed: credentials are proxy-admin only, so
a non-admin POST to ``/credentials/migrate-encryption`` is rejected at the
route-level gate (401) before the handler or any DB read/write runs.
"""
gen = await proxy_client.post(
"/key/generate",
@ -72,4 +69,4 @@ async def test_migrate_encryption_requires_admin(proxy_client, scratch):
"/credentials/migrate-encryption",
headers={"Authorization": f"Bearer {nonadmin_key}"},
)
assert resp.status_code == 403, resp.text
assert resp.status_code == 401, resp.text

View file

@ -3091,7 +3091,7 @@ def test_internal_user_blocked_from_search_tool_writes(route):
assert "Your role=internal_user" in str(exc_info.value)
# --- Credential route gating (PR #30873: /credentials opened to self-managed) --- #
# --- Credential route gating (PR #30873: /credentials is proxy-admin only) --- #
@pytest.mark.parametrize(

View file

@ -88,13 +88,7 @@ async def test_create_logging_credential_allowed_for_admin(_connected_db):
@pytest.mark.asyncio
async def test_create_provider_credential_forbidden_for_non_admin(_connected_db):
"""POST is proxy-admin only even for provider credentials.
The route gate now lets non-admins reach the credentials path so they can
PATCH ``access.teams`` on logging destinations they should be able to
self-assign. POST remains admin-only to keep credential creation a
platform-admin concern.
"""
"""POST is proxy-admin only for provider and logging credentials alike."""
with pytest.raises(HTTPException) as exc:
await endpoints.create_credential(
request=MagicMock(),
@ -191,11 +185,9 @@ def test_update_db_credential_preserves_existing_info_on_partial_patch():
def test_update_db_credential_preserves_untouched_access_subfields():
"""Veria regression: an access patch carrying only `teams` must NOT clobber
existing `global` / `orgs`. Pre-fix this caused a scope-tampering bug
the decider only allowed team-admin patches that touched access.teams,
but the merge replaced the entire access object, silently dropping
access.global=true and any access.orgs entries.
"""An access patch carrying only `teams` must NOT clobber existing
`global` / `orgs`: a top-level replace of the access object would silently
drop access.global=true and any access.orgs entries the patch never named.
"""
from litellm.proxy.credential_endpoints.endpoints import update_db_credential
@ -213,8 +205,7 @@ def test_update_db_credential_preserves_untouched_access_subfields():
},
},
)
# Team-admin's allowed shape: add their team to access.teams. Crucially,
# they don't (and per the decider can't) include global/orgs.
# A partial patch touching only access.teams; global/orgs are not sent.
patch = CredentialItem(
credential_name="dest",
credential_values={},
@ -314,19 +305,14 @@ async def test_delete_db_only_logging_credential_forbidden_for_non_admin(
_connected_db.delete_by_name.assert_not_awaited()
# --- team-admin self-assign tests (LIT-3850 follow-up) ----------------------
# --- PATCH gating (proxy-admin only) ----------------------------------------
@pytest.mark.asyncio
async def test_provider_credential_patch_forbidden_for_non_admin(
_connected_db, monkeypatch
):
"""A team-admin (or any non-admin) cannot PATCH a non-logging credential.
The route gate was widened to let team-admins reach /credentials/{name}
for logging-credential access edits. A provider credential is not
is_admin_gated_credential_info, so the decider block is skipped; without
an explicit else-branch a team-admin could rotate the upstream api_key.
"""
"""A non-admin cannot PATCH a provider credential (or any credential):
without the gate a non-admin could rotate the upstream api_key."""
provider_cred = CredentialItem(
credential_name="openai-prod",
credential_values={"api_key": "sk-real"},

View file

@ -1,9 +1,8 @@
"""The shared visibility predicate for admin-owned logging destinations.
"""The request-time routing predicate for admin-owned logging destinations.
This is the single chokepoint the list endpoint, the assignment validator, and
the request-time resolver all route through, so a mutation here would let a
non-admin see or route to a destination outside their scope. Each case is written
to fail if the corresponding branch is flipped.
``access_grants`` is the chokepoint the resolver routes through at call time, so a
mutation here would route an identity's traces to a destination outside its scope.
Each case is written to fail if the corresponding branch is flipped.
"""
import os
@ -15,7 +14,6 @@ from litellm.models.credentials import CredentialAccess, CredentialInfo
from litellm.proxy.management_endpoints.logging_exporter_access import (
access_grants,
identity_scope,
is_destination_visible,
parse_credential_info,
)
@ -99,54 +97,55 @@ def test_access_grants_not_global_when_false():
assert access_grants(a, frozenset({"t1"}), frozenset({"o1"})) is False
# --- is_destination_visible: decided entirely by access --------------------
# --- routing scope decided entirely by access -------------------------------
#
# Visibility is access-only. auto_enable does not affect it: an empty-access
# destination is invisible regardless of auto_enable (empty access = deny-all).
# Proxy-wide visibility must be requested explicitly with access.global=True.
# 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.
def test_visible_empty_access_is_deny_all_even_with_auto_enable():
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)
assert is_destination_visible(info, frozenset(), frozenset()) is False
assert is_destination_visible(info, frozenset({"any-team"}), frozenset()) is False
assert is_destination_visible(info, frozenset(), frozenset({"any-org"})) is False
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_visible_global_access_is_proxy_wide():
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))
assert is_destination_visible(info, frozenset({"t1"}), frozenset()) is True
assert is_destination_visible(info, frozenset(), frozenset()) is 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 is_destination_visible(manual, frozenset(), frozenset()) is True
assert access_grants(manual.access, frozenset(), frozenset()) is True
def test_visible_auto_enable_team_scoped():
"""auto_enable=True + access.teams=[t1] is visible only to t1 admins."""
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"]))
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
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_visible_auto_enable_org_scoped():
"""auto_enable=True + access.orgs=[o1] is visible only to o1 admins."""
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"]))
assert is_destination_visible(info, frozenset(), frozenset({"o1"})) is True
assert is_destination_visible(info, frozenset(), frozenset({"o2"})) is False
assert access_grants(info.access, frozenset(), frozenset({"o1"})) is True
assert access_grants(info.access, frozenset(), frozenset({"o2"})) is False
def test_visible_delegates_to_access_when_not_auto_enable():
def test_access_scoped_when_not_auto_enable():
info = CredentialInfo(credential_type="logging", 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 access_grants(info.access, frozenset({"t1"}), frozenset()) is True
assert access_grants(info.access, frozenset({"t2"}), frozenset()) is False
def test_visible_denies_when_neither():
def test_denies_when_no_access():
info = CredentialInfo(credential_type="logging")
assert is_destination_visible(info, frozenset({"t1"}), frozenset({"o1"})) is False
assert access_grants(info.access, frozenset({"t1"}), frozenset({"o1"})) is False
# --- identity_scope --------------------------------------------------------

View file

@ -19,7 +19,6 @@ import litellm
from litellm.models.credentials import CredentialItem
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
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,
@ -101,9 +100,6 @@ def test_non_admin_with_no_flags_is_forbidden(_registry):
assert exc.value.status_code == 403
# --- Scope checks: a non-proxy-admin may only name destinations granted to them -
# --- Shape / registry checks (run regardless of who's calling) --------------
@ -210,22 +206,7 @@ def test_omitted_on_both_sides_is_noop(_registry):
)
# --- is_admin_gated_credential_info / validate_credential_access ------------
@pytest.mark.parametrize(
"credential_info, gated",
[
({"credential_type": "logging"}, True),
({"access": {"global": True}}, True),
({"credential_type": "logging", "access": {"teams": ["t"]}}, True),
({"custom_llm_provider": "openai"}, False),
({}, False),
(None, False),
],
)
def test_is_admin_gated_credential_info(credential_info, gated):
assert is_admin_gated_credential_info(credential_info) is gated
# --- validate_credential_access ---------------------------------------------
def test_validate_credential_access_accepts_valid_object():

View file

@ -23,6 +23,7 @@ type LoggingCallbacksProps = {
onDelete?: (callback: AlertingObject) => void;
onEditAccess?: (callback: AlertingObject) => void;
onAdd?: () => void;
readOnly?: boolean;
};
function EmptyState() {
@ -48,21 +49,25 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
onDelete = () => {},
onEditAccess = () => {},
onAdd = () => {},
readOnly = false,
}) => {
const columns = useMemo(() => {
const deps = { availableCallbacks, onTest, onEdit, onDelete, onEditAccess };
return getLoggingCallbacksTableColumns(deps);
}, [availableCallbacks, onTest, onEdit, onDelete, onEditAccess]);
const all = getLoggingCallbacksTableColumns(deps);
return readOnly ? all.filter((column) => column.id !== "actions") : all;
}, [availableCallbacks, onTest, onEdit, onDelete, onEditAccess, readOnly]);
return (
<div className="mt-4 flex w-full flex-col gap-4">
<h3 className="text-lg font-semibold tracking-tight text-foreground">Active Logging Callbacks</h3>
<div>
<Button onClick={onAdd}>
<Plus />
Add Callback
</Button>
</div>
{!readOnly && (
<div>
<Button onClick={onAdd}>
<Plus />
Add Callback
</Button>
</div>
)}
<DataTable
data={callbacks as CallbackRow[]}
columns={columns}

View file

@ -47,7 +47,7 @@ interface EditTeamModalProps {
}
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import LoggingExportersSelect from "./logging_credentials/LoggingExportersSelect";
import { LoggingExportersFormItem } from "./logging_credentials/LoggingExportersSelect";
import { teamCreateCall } from "./networking";
import { ModelSelect } from "./ModelSelect/ModelSelect";
@ -1101,14 +1101,10 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
<b>Logging Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item
label="Logging Exporters"
name="logging_exporters"
<LoggingExportersFormItem
tooltip="Admin-owned trace destinations this team exports to. Resolved server-side and fanned out (added to the key's and org's). Manage destinations under Settings -> Logging Callbacks."
className="mt-4"
>
<LoggingExportersSelect />
</Form.Item>
/>
<div className="mt-4">
<PremiumLoggingSettings
value={loggingSettings}

View file

@ -30,13 +30,13 @@ const AccessControlFields: React.FC<AccessControlFieldsProps> = ({ value = {}, o
<>
<Form.Item
label="Global"
tooltip="Visibility only: every team and org can see and assign this destination. It does not turn on tracing by itself -- name it on a key/team/org, or use Auto-enable, for that."
tooltip="Routing scope only: traces from every team and org may export to this destination. It does not turn on tracing by itself -- assign it on a key/team/org, or use Auto-enable, for that."
>
<Switch checked={isGlobal} onChange={(global) => onChange({ ...value, global })} />
</Form.Item>
<Form.Item
label="Teams"
tooltip="Admins of these teams can see and assign this destination; their keys export to it once it is named."
tooltip="Routing scope: only these teams' traffic may export to this destination, once it is auto-enabled or assigned."
>
<Select
mode="multiple"
@ -52,7 +52,7 @@ const AccessControlFields: React.FC<AccessControlFieldsProps> = ({ value = {}, o
</Form.Item>
<Form.Item
label="Organizations"
tooltip="Admins of these orgs can see and assign this destination; their keys export to it once it is named."
tooltip="Routing scope: only these orgs' traffic may export to this destination, once it is auto-enabled or assigned."
>
<Select
mode="multiple"

View file

@ -81,16 +81,16 @@ describe("LoggingExportersSelect", () => {
render(<LoggingExportersSelect value={[]} onChange={() => {}} />);
expect(screen.queryAllByTestId("option")).toHaveLength(0);
expect(screen.getByTestId("empty").textContent).toMatch(/proxy admin/i);
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 already scoped server-side (proxy admin -> all; team/org
// admin -> only in-scope destinations) by the same predicate the assignment gate
// and the resolver use. The picker must therefore render every logging-typed
// destination in the response verbatim; re-filtering here by role/scope on the
// client would risk disagreeing with the authoritative backend in either
// direction. This response mixes access shapes to prove none are dropped locally.
// 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: [

View file

@ -1,4 +1,4 @@
import { Select } from "antd";
import { Form, Select } from "antd";
import React from "react";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
@ -47,9 +47,32 @@ const LoggingExportersSelect: React.FC<LoggingExportersSelectProps> = ({ value,
options={options}
style={{ width: "100%" }}
optionFilterProp="label"
notFoundContent="No logging destinations available. Ask your proxy admin to create one under Settings -> Logging Callbacks."
notFoundContent="No logging destinations available. Create one under Settings -> Logging Callbacks."
/>
);
};
export default LoggingExportersSelect;
interface LoggingExportersFormItemProps {
tooltip: string;
className?: string;
}
/**
* The antd Form.Item wrapper for LoggingExportersSelect, gated to proxy admins so
* non-admin forms render neither the picker nor an orphaned "Logging Exporters"
* label. Keeps the role gate in one place for every antd form that binds the
* logging_exporters field.
*/
export const LoggingExportersFormItem: React.FC<LoggingExportersFormItemProps> = ({ tooltip, className }) => {
const { userRole } = useAuthorized();
if (userRole == null || !isProxyAdminRole(userRole)) {
return null;
}
return (
<Form.Item label="Logging Exporters" name="logging_exporters" tooltip={tooltip} className={className}>
<LoggingExportersSelect />
</Form.Item>
);
};

View file

@ -4,7 +4,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import LoggingExportersSelect from "@/components/logging_credentials/LoggingExportersSelect";
import { isProxyAdminRole } from "@/utils/roles";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import NotificationsManager from "@/components/molecules/notifications_manager";
@ -43,6 +45,8 @@ export const OrgCreateDialog = ({
}: OrgCreateDialogProps) => {
const queryClient = useQueryClient();
const form = useZodForm(orgSettingsSchema, { defaultValues: emptyOrgFormValues });
const { userRole } = useAuthorized();
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
const closeAndReset = () => {
form.reset(emptyOrgFormValues);
@ -162,14 +166,16 @@ export const OrgCreateDialog = ({
)}
</FormField>
<FormField
control={form.control}
name="logging_exporters"
label="Logging Exporters"
description="Admin-owned trace destinations every team and key in this org exports to. Manage destinations under Settings -> Logging Callbacks."
>
{(field) => <LoggingExportersSelect value={field.value} onChange={field.onChange} />}
</FormField>
{isProxyAdmin && (
<FormField
control={form.control}
name="logging_exporters"
label="Logging Exporters"
description="Admin-owned trace destinations every team and key in this org exports to. Manage destinations under Settings -> Logging Callbacks."
>
{(field) => <LoggingExportersSelect value={field.value} onChange={field.onChange} />}
</FormField>
)}
<FormField control={form.control} name="metadata" label="Metadata">
{({ ref, ...field }) => <Textarea {...field} ref={ref} rows={4} />}

View file

@ -4,6 +4,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import NotificationsManager from "@/components/molecules/notifications_manager";
@ -59,6 +61,8 @@ export const OrgSettingsForm = ({
}: OrgSettingsFormProps) => {
const queryClient = useQueryClient();
const form = useZodForm(orgSettingsSchema, { defaultValues: orgToForm(org) });
const { userRole } = useAuthorized();
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
const { isDirty } = form.formState;
const mutation = useMutation({
@ -151,14 +155,16 @@ export const OrgSettingsForm = ({
)}
</FormField>
<FormField
control={form.control}
name="logging_exporters"
label="Logging Exporters"
description="Admin-owned trace destinations every team in this org exports to. Manage destinations under Settings -> Logging Callbacks."
>
{(field) => <LoggingExportersSelect value={field.value} onChange={field.onChange} />}
</FormField>
{isProxyAdmin && (
<FormField
control={form.control}
name="logging_exporters"
label="Logging Exporters"
description="Admin-owned trace destinations every team in this org exports to. Manage destinations under Settings -> Logging Callbacks."
>
{(field) => <LoggingExportersSelect value={field.value} onChange={field.onChange} />}
</FormField>
)}
<FormField control={form.control} name="metadata" label="Metadata">
{({ ref, ...field }) => <Textarea {...field} ref={ref} rows={4} />}

View file

@ -62,8 +62,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
// Destinations whose credential_info.access targets THIS org (or is global).
// Rendered alongside the org's own metadata.logging_exporters so the Logging
// Exporters card reflects BOTH routing directions, matching the resolver's
// union at request time.
const { data: orgCredentialsData } = useCredentials();
// union at request time. GET /credentials is proxy-admin only, so the fetch is
// skipped (and the scoped list stays empty) for other roles.
const { data: orgCredentialsData } = useCredentials(is_proxy_admin);
const scopedExportersForOrg = useMemo<string[]>(() => {
const orgId = orgData?.organization_id;
if (orgId == null) return [];

View file

@ -90,7 +90,7 @@ beforeAll(() => {
describe("Settings", () => {
const defaultProps = {
accessToken: "token",
userRole: "admin",
userRole: "Admin",
userID: "user-123",
premiumUser: false,
};

View file

@ -42,7 +42,7 @@ import {
import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable";
import { AlertingObject, CredentialAccess, ResolvedScope } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { isProxyAdminRole } from "@/utils/roles";
import { canReadCredentialsRole, isProxyAdminRole } from "@/utils/roles";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import EditLoggingCredentialModal from "./logging_credentials/EditLoggingCredentialModal";
@ -264,11 +264,8 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
// credential_type=logging; they share the one Active Logging Callbacks table as
// rows alongside config callbacks. Only a proxy admin (or admin-viewer, read-only)
// may read them, so non-admins skip the fetch entirely.
const canReadCredentials =
(userRole ? isProxyAdminRole(userRole) : false) ||
userRole === "Admin Viewer" ||
userRole === "proxy_admin_viewer";
const { data: credentialData, refetch: refetchCredentials } = useCredentials(canReadCredentials);
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
const { data: credentialData, refetch: refetchCredentials } = useCredentials(canReadCredentialsRole(userRole));
const { data: teamsData } = useTeams();
const { data: orgsData } = useOrganizations();
const [editAccessFor, setEditAccessFor] = useState<{ name: string; access?: CredentialAccess } | null>(null);
@ -706,6 +703,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
callbacks={[...callbacks.filter((c) => !NON_CALLBACK_LOGGING_IDS.has(c.name)), ...destinationRows]}
availableCallbacks={allCallbacks}
isLoading={isLoadingCallbacks}
readOnly={!isProxyAdmin}
onAdd={() => setShowAddCallbacksModal(true)}
onEdit={(cb) => {
setSelectedEditCallback(cb);

View file

@ -18,7 +18,7 @@ import { useGuardrails, GuardrailListItem } from "@/app/(dashboard)/hooks/guardr
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import type { ObjectPermission } from "@/components/object_permission_types";
import { isProxyAdminRole } from "@/utils/roles";
import { canReadCredentialsRole, isProxyAdminRole } from "@/utils/roles";
import {
EditOutlined,
GlobalOutlined,
@ -54,7 +54,7 @@ import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import SearchToolSelector from "../search_tools/SearchToolSelector";
import EditLoggingSettings from "./EditLoggingSettings";
import LoggingExportersSelect from "../logging_credentials/LoggingExportersSelect";
import { LoggingExportersFormItem } from "../logging_credentials/LoggingExportersSelect";
import { loggingExportersOf } from "../logging_credentials/loggingExportersOf";
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
import MemberModal from "./EditMembership";
@ -233,8 +233,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
// Destinations whose credential_info.access targets this team (or its org, or
// global). Rendered alongside the team's own metadata.logging_exporters so the
// Logging Exporters card reflects BOTH routing directions, matching the
// resolver's union at request time.
const { data: scopedCredentialsData } = useCredentials();
// resolver's union at request time. GET /credentials is proxy-admin only, so
// the fetch is skipped (and the scoped list stays empty) for other roles.
const { data: scopedCredentialsData } = useCredentials(canReadCredentialsRole(userRole));
const scopedExportersForTeam = useMemo<string[]>(() => {
const orgId = teamData?.team_info?.organization_id ?? null;
return (scopedCredentialsData?.credentials ?? [])
@ -1481,13 +1482,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item
label="Logging Exporters"
name="logging_exporters"
tooltip="Trace destinations this team exports to. Resolved server-side and unioned with the key's and org's destinations. Destinations are created by the proxy admin; team admins may attach any of them to teams they admin."
>
<LoggingExportersSelect />
</Form.Item>
<LoggingExportersFormItem tooltip="Trace destinations this team exports to. Resolved server-side and unioned with the key's and org's destinations. Destinations are created and assigned by the proxy admin." />
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings

View file

@ -36,7 +36,7 @@ import { fetchTeamModels } from "../organisms/create_key_button";
import NumericalInput from "../shared/numerical_input";
import { Tag } from "../tag_management/types";
import EditLoggingSettings from "../team/EditLoggingSettings";
import LoggingExportersSelect from "../logging_credentials/LoggingExportersSelect";
import { LoggingExportersFormItem } from "../logging_credentials/LoggingExportersSelect";
import { loggingExportersOf } from "../logging_credentials/loggingExportersOf";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
@ -834,9 +834,7 @@ export function KeyEditView({
<Input value={projectDisplay ?? ""} disabled />
</Form.Item>
)}
<Form.Item label="Logging Exporters" name="logging_exporters" tooltip="Trace destinations this key exports to.">
<LoggingExportersSelect />
</Form.Item>
<LoggingExportersFormItem tooltip="Trace destinations this key exports to." />
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings

View file

@ -9,7 +9,12 @@ import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels,
import { Form, Modal, Tag } from "antd";
import { KeyInfoHeader } from "./KeyInfoHeader";
import { useEffect, useMemo, useState } from "react";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles";
import {
canReadCredentialsRole,
isProxyAdminRole,
isUserTeamAdminForSingleTeam,
rolesWithWriteAccess,
} from "../../utils/roles";
import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers";
import AutoRotationView from "../common_components/AutoRotationView";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
@ -73,7 +78,7 @@ export default function KeyInfoView({
const queryClient = useQueryClient();
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
const { teams: teamsData } = useTeams();
const { data: keyCredentialsData } = useCredentials();
const { data: keyCredentialsData } = useCredentials(canReadCredentialsRole(userRole));
const { data: keyOrganizationsData } = useOrganizations();
const { data: projects } = useProjects();
const { data: uiSettingsData } = useUISettings();

View file

@ -2485,13 +2485,10 @@ export interface paths {
* Get Credentials
* @description [BETA] endpoint. This might change unexpectedly.
*
* Proxy admins see every credential (values masked). A non-proxy-admin sees
* only the logging destinations actually visible to a scope they administer:
* the same ``is_destination_visible`` predicate the assignment validator and
* the request-time resolver use, so the list can never show a destination a
* caller could neither assign nor route to. Provider credentials, and logging
* destinations scoped to other tenants, stay invisible. A caller who
* administers nothing gets 403 (Veria F2).
* Proxy-admin only (a proxy-admin-viewer may read). Credentials, including
* admin-owned logging destinations, are managed exclusively by the proxy admin;
* tenants never read them over the API. Values are masked for the admin and
* fully redacted for the admin-viewer.
*/
get: operations["get_credentials_credentials_get"];
put?: never;
@ -2615,10 +2612,8 @@ export interface paths {
* Update Credential
* @description [BETA] endpoint. This might change unexpectedly.
*
* Both ``credential_values`` and ``credential_info`` are optional; a team-admin
* typically patches only ``credential_info.access`` to grant or revoke their
* own team. A proxy admin may patch either or both. See
* ``decide_credential_patch`` for the exact contract.
* Proxy-admin only. Credentials, including admin-owned logging destinations and
* their ``access`` scoping, are managed exclusively by the proxy admin.
*/
patch: operations["update_credential_credentials__credential_name__patch"];
trace?: never;
@ -32395,10 +32390,10 @@ export interface components {
* UpdateCredentialItem
* @description PATCH body for ``/credentials/{name}``.
*
* Both ``credential_values`` and ``credential_info`` are optional so a caller
* can patch one without sending the other (team-admins patching access without
* knowing the upstream secrets; proxy admins rotating values without touching
* access). ``credential_name`` is optional because most patches don't rename.
* Both ``credential_values`` and ``credential_info`` are optional so the proxy
* admin can patch one without sending the other (rotating values without
* touching access, or adjusting access without re-sending secrets).
* ``credential_name`` is optional because most patches don't rename.
*/
UpdateCredentialItem: {
/** Credential Info */

View file

@ -23,6 +23,11 @@ export const isProxyAdminRole = (role: string): boolean => {
return role === "proxy_admin" || role === "Admin";
};
// Roles allowed to read GET /credentials (proxy admin, plus the read-only admin viewer)
export const canReadCredentialsRole = (role: string | null | undefined): boolean => {
return role != null && (isProxyAdminRole(role) || role === "Admin Viewer" || role === "proxy_admin_viewer");
};
export const isUserTeamAdminForAnyTeam = (teams: Team[] | null, userID: string): boolean => {
if (teams == null) {
return false;