fix(credentials): scope access validation and credential_info merge to logging destinations

Three collateral regressions surfaced while reviewing this PR, each narrowing feature-added
behavior back toward base for non-destination paths. validate_credential_access now runs only
for logging destinations, so a provider credential carrying an unrelated access key is no longer
rejected with a 400. update_db_credential and _sync_in_memory_credential subfield-merge
credential_info only for logging destinations; provider credentials keep base replace semantics.
regenerate_key_fn no longer swallows the team-lookup 404, so regenerating a key whose team was
deleted fails like base instead of silently proceeding against a dangling team

The logging-destination merge preserves credential_type, description, and the untouched access
subfields, which the Edit-access UI modal depends on since it patches only access. The gate keys
off the credential_type tag alone (not a full parse of access) so a destination with a malformed
access is still routed into validation and rejected. Follow-up LIT-5004 tracks the general
/credentials PATCH replace-vs-merge decision for all credential types
This commit is contained in:
Yucheng Zhu 2026-07-29 21:56:22 -07:00
parent f8500bf23e
commit 22b04d0f67
6 changed files with 229 additions and 21 deletions

View file

@ -14,6 +14,7 @@ from litellm.litellm_core_utils.litellm_logging import _get_masked_values
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.management_endpoints.logging_exporter_access import is_logging_credential
from litellm.proxy.management_endpoints.logging_exporter_validation import ( from litellm.proxy.management_endpoints.logging_exporter_validation import (
validate_credential_access, validate_credential_access,
) )
@ -65,7 +66,8 @@ async def create_credential(
""" """
from litellm.proxy.proxy_server import llm_router, prisma_client from litellm.proxy.proxy_server import llm_router, prisma_client
validate_credential_access(credential.credential_info) if is_logging_credential(credential.credential_info):
validate_credential_access(credential.credential_info)
try: try:
if prisma_client is None: if prisma_client is None:
@ -285,9 +287,12 @@ def update_db_credential(
merged_credential.credential_values.update(encrypted_params) merged_credential.credential_values.update(encrypted_params)
if encrypted_credential.credential_info: if encrypted_credential.credential_info:
if merged_credential.credential_info is None: if is_logging_credential(db_credential.credential_info):
merged_credential.credential_info = {} if merged_credential.credential_info is None:
_merge_credential_info(merged_credential.credential_info, encrypted_credential.credential_info) merged_credential.credential_info = {}
_merge_credential_info(merged_credential.credential_info, encrypted_credential.credential_info)
else:
merged_credential.credential_info = encrypted_credential.credential_info
return merged_credential return merged_credential
@ -344,8 +349,6 @@ async def update_credential(
""" """
from litellm.proxy.proxy_server import prisma_client from litellm.proxy.proxy_server import prisma_client
validate_credential_access(credential.credential_info)
try: try:
if prisma_client is None: if prisma_client is None:
raise HTTPException( raise HTTPException(
@ -356,6 +359,8 @@ async def update_credential(
db_credential = await credentials_repository.find_by_name(credential_name) db_credential = await credentials_repository.find_by_name(credential_name)
if db_credential is None: if db_credential is None:
raise HTTPException(status_code=404, detail="Credential not found in DB.") raise HTTPException(status_code=404, detail="Credential not found in DB.")
if is_logging_credential(db_credential.credential_info) or is_logging_credential(credential.credential_info):
validate_credential_access(credential.credential_info)
merged_credential = update_db_credential(db_credential, _patch_to_credential_item(credential, credential_name)) merged_credential = update_db_credential(db_credential, _patch_to_credential_item(credential, credential_name))
credential_object_jsonified = jsonify_object(merged_credential.model_dump()) credential_object_jsonified = jsonify_object(merged_credential.model_dump())
await credentials_repository.update_by_name( await credentials_repository.update_by_name(
@ -384,10 +389,11 @@ def _sync_in_memory_credential(
"""Mirror the DB write into ``litellm.credential_list``. """Mirror the DB write into ``litellm.credential_list``.
Skips when the credential isn't resident in memory (e.g. created on Skips when the credential isn't resident in memory (e.g. created on
another scaled instance, restored from DB on the next reload). The another scaled instance, restored from DB on the next reload). For a
in-memory ``credential_info`` is merged subfield-by-subfield via logging destination the in-memory ``credential_info`` is merged
``_merge_credential_info`` so a partial patch can't clobber stored subfield-by-subfield via ``_merge_credential_info`` so a partial patch
``access`` subfields it didn't touch. can't clobber stored ``access`` subfields it didn't touch; a provider
credential keeps the base replace semantics.
""" """
existing_in_memory: CredentialItem | None = None existing_in_memory: CredentialItem | None = None
for cred in litellm.credential_list: for cred in litellm.credential_list:
@ -402,7 +408,10 @@ def _sync_in_memory_credential(
in_memory_values.update(patch.credential_values) in_memory_values.update(patch.credential_values)
in_memory_info = dict(existing_in_memory.credential_info or {}) in_memory_info = dict(existing_in_memory.credential_info or {})
if patch.credential_info: if patch.credential_info:
_merge_credential_info(in_memory_info, patch.credential_info) if is_logging_credential(existing_in_memory.credential_info):
_merge_credential_info(in_memory_info, patch.credential_info)
else:
in_memory_info = dict(patch.credential_info)
updated_in_memory = CredentialItem( updated_in_memory = CredentialItem(
credential_name=merged.credential_name, credential_name=merged.credential_name,
credential_values=in_memory_values, credential_values=in_memory_values,

View file

@ -4805,15 +4805,12 @@ async def regenerate_key_fn( # noqa: C901 # single endpoint handling many opti
if data is not None and (data.access_group_ids or data.object_permission is not None): if data is not None and (data.access_group_ids or data.object_permission is not None):
regenerate_team_table: LiteLLM_TeamTableCachedObj | None = None regenerate_team_table: LiteLLM_TeamTableCachedObj | None = None
if _key_in_db.team_id is not None: if _key_in_db.team_id is not None:
try: regenerate_team_table = await get_team_object(
regenerate_team_table = await get_team_object( team_id=_key_in_db.team_id,
team_id=_key_in_db.team_id, prisma_client=prisma_client,
prisma_client=prisma_client, user_api_key_cache=user_api_key_cache,
user_api_key_cache=user_api_key_cache, check_db_only=True,
check_db_only=True, )
)
except HTTPException:
regenerate_team_table = None
_regen_is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value _regen_is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups( TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=user_api_key_dict, user_api_key_dict=user_api_key_dict,

View file

@ -10,12 +10,20 @@ scope is the given set of team ids and org ids. The resolver passes a
one-element scope built with ``identity_scope``. one-element scope built with ``identity_scope``.
""" """
from pydantic import ValidationError from pydantic import BaseModel, ConfigDict, ValidationError
import litellm import litellm
from litellm.models.credentials import CredentialAccess, CredentialInfo from litellm.models.credentials import CredentialAccess, CredentialInfo
class _LoggingDestinationTag(BaseModel):
"""Lenient read of just the ``credential_type`` tag, ignoring the rest of
``credential_info`` (including a possibly-malformed ``access``)."""
model_config = ConfigDict(extra="ignore")
credential_type: str | None = None
def parse_credential_info(raw: object) -> CredentialInfo | None: def parse_credential_info(raw: object) -> CredentialInfo | None:
"""Parse stored ``credential_info`` into the typed model, or ``None`` when it is """Parse stored ``credential_info`` into the typed model, or ``None`` when it is
absent or malformed. absent or malformed.
@ -32,6 +40,23 @@ def parse_credential_info(raw: object) -> CredentialInfo | None:
return None return None
def is_logging_credential(raw: object) -> bool:
"""Whether ``credential_info`` is tagged as an admin-owned logging destination.
The ``access`` shape validation and the ``credential_info`` subfield merge are
scoped to these; a provider credential is left on its base replace-and-accept path.
This keys off the ``credential_type`` tag alone and does not parse ``access``: a
destination carrying a malformed ``access`` is still a logging destination, and the
point of the gate is to route it into ``validate_credential_access`` so that bad
``access`` is rejected rather than stored.
"""
try:
return _LoggingDestinationTag.model_validate(raw).credential_type == "logging"
except ValidationError:
return False
def identity_scope(team_id: str | None, org_id: str | None) -> tuple[frozenset[str], frozenset[str]]: def identity_scope(team_id: str | None, org_id: str | None) -> tuple[frozenset[str], frozenset[str]]:
"""A single request identity's scope as ``(team_ids, org_ids)`` for """A single request identity's scope as ``(team_ids, org_ids)`` for
``access_grants``.""" ``access_grants``."""

View file

@ -97,6 +97,80 @@ def test_update_db_credential_preserves_untouched_access_subfields():
} }
# --- provider credentials keep base replace semantics (merge is logging-only) ---
def test_update_db_credential_replaces_info_for_provider_credential():
"""The subfield merge is scoped to logging destinations. A provider credential
patch replaces credential_info wholesale (base behavior): omitted keys drop, so a
partial patch is a full replace, not a merge -- merging non-destination creds would
silently resurrect stale provider metadata the caller meant to remove."""
from litellm.proxy.credential_endpoints.endpoints import update_db_credential
db = CredentialItem(
credential_name="openai",
credential_values={},
credential_info={"custom_llm_provider": "openai", "stale": "keepout"},
)
patch = CredentialItem(
credential_name="openai",
credential_values={},
credential_info={"custom_llm_provider": "azure"},
)
merged = update_db_credential(db, patch)
assert merged.credential_info == {"custom_llm_provider": "azure"}
# --- access-shape validation is scoped to logging destinations ---------------
@pytest.mark.asyncio
async def test_create_credential_validates_access_only_for_logging(monkeypatch):
"""validate_credential_access runs for a logging destination but never for a
provider credential. A provider cred carrying an unrelated `access` key must not be
rejected by the destination access-shape validator (that would 400 a valid
provider credential the validator was never meant to see)."""
import litellm.proxy.proxy_server as proxy_server
from litellm.types.utils import CreateCredentialItem
monkeypatch.setattr(proxy_server, "prisma_client", None, raising=False)
class _Validated(Exception):
pass
def _spy(_info):
raise _Validated()
monkeypatch.setattr(endpoints, "validate_credential_access", _spy)
async def _create(credential):
return await endpoints.create_credential(
request=MagicMock(),
fastapi_response=MagicMock(),
credential=credential,
user_api_key_dict=_admin(),
)
logging_cred = CreateCredentialItem(
credential_name="dest",
credential_values={"otel_endpoint": "http://collector:4318"},
credential_info={"credential_type": "logging", "description": "generic", "access": {"global": True}},
)
with pytest.raises(_Validated):
await _create(logging_cred)
provider_cred = CreateCredentialItem(
credential_name="openai",
credential_values={"api_key": "sk"},
credential_info={"custom_llm_provider": "openai", "access": {"bogus": True}},
)
# Validator is skipped; the handler proceeds and fails on the (None) prisma client,
# a 500 -- never the _Validated sentinel.
with pytest.raises(Exception) as excinfo:
await _create(provider_cred)
assert not isinstance(excinfo.value, _Validated)
# --- PATCH routing regression ------------------------------------------------ # --- PATCH routing regression ------------------------------------------------
def test_patch_credentials_route_targets_update_credential(): def test_patch_credentials_route_targets_update_credential():

View file

@ -13162,6 +13162,87 @@ async def test_regenerate_applies_normalized_mcp_object_permission():
assert regenerated_data.object_permission.mcp_servers == ["server-id"] assert regenerated_data.object_permission.mcp_servers == ["server-id"]
@pytest.mark.asyncio
async def test_regenerate_propagates_team_not_found():
"""Regenerating a key whose team no longer exists must surface the team-lookup
404 rather than swallowing it and regenerating against a dangling team. The
access_group_ids/object_permission gate depends on the resolved team, so a missing
team must abort the regenerate (matching /key/generate and /key/update) instead of
silently continuing with team_table=None."""
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
regenerate_key_fn,
)
data = RegenerateKeyRequest(key="sk-old", access_group_ids=["ag-1"])
existing_key = LiteLLM_VerificationToken(
token="abc123",
user_id="user-1",
models=["gpt-4"],
team_id="dangling-team",
max_budget=None,
tags=None,
)
mock_prisma_client = AsyncMock()
mock_repo = MagicMock()
mock_repo.table.find_unique = AsyncMock(return_value=existing_key)
execute_mock = AsyncMock(return_value=MagicMock())
enforce_mock = MagicMock()
async def _raise_team_not_found(*args, **kwargs):
raise HTTPException(status_code=404, detail={"error": "Team not found"})
with (
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.proxy.proxy_server.master_key", None),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.hash_token", lambda token: "hashed-old"),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.VerificationTokenRepository",
return_value=mock_repo,
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint",
new_callable=AsyncMock,
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.enforce_member_can_assign_access_groups",
enforce_mock,
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.can_modify_verification_token",
new_callable=AsyncMock,
return_value=True,
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints.get_team_object",
_raise_team_not_found,
),
patch(
"litellm.proxy.management_endpoints.key_management_endpoints._execute_virtual_key_regeneration",
execute_mock,
),
):
with pytest.raises((HTTPException, ProxyException)) as exc_info:
await regenerate_key_fn(
key="sk-old",
data=data,
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN.value,
api_key="sk-admin",
user_id="admin",
),
)
err = exc_info.value
code = getattr(err, "status_code", None) or getattr(err, "code", None)
assert str(code) == "404"
enforce_mock.assert_not_called()
execute_mock.assert_not_awaited()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Regression tests for GHSA-q775-qw9r-2r4g: budget escalation via key/generate # Regression tests for GHSA-q775-qw9r-2r4g: budget escalation via key/generate
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -15,11 +15,33 @@ from litellm.models.credentials import CredentialAccess, CredentialInfo, Credent
from litellm.proxy.management_endpoints.logging_exporter_access import ( from litellm.proxy.management_endpoints.logging_exporter_access import (
access_grants, access_grants,
identity_scope, identity_scope,
is_logging_credential,
parse_credential_info, parse_credential_info,
resolved_logging_exporter_names, resolved_logging_exporter_names,
) )
# --- is_logging_credential: the access-validation + merge gate ---------------
def test_is_logging_credential_true_for_logging_type():
assert is_logging_credential({"credential_type": "logging", "description": "arize"}) is True
def test_is_logging_credential_true_even_with_malformed_access():
"""A destination carrying an invalid access shape is still a logging destination.
The gate must route it into validate_credential_access (which rejects it), not skip
validation because the strict access model can't parse it."""
assert is_logging_credential({"credential_type": "logging", "access": {"nonsense_field": True}}) is True
def test_is_logging_credential_false_for_provider_and_malformed():
assert is_logging_credential({"custom_llm_provider": "openai"}) is False
assert is_logging_credential({"custom_llm_provider": "openai", "access": {"global": True}}) is False
assert is_logging_credential(None) is False
assert is_logging_credential("nope") is False
# --- parse_credential_info: fail closed on bad input ----------------------- # --- parse_credential_info: fail closed on bad input -----------------------