fix(proxy): align ownership and scope handling (#50600)
Some checks failed
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / OpenAI 2.20.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.0.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.25.0 compatibility (push) Blocked by required conditions
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Cost map pins recheck / rerun-cost-map-guard (push) Has been cancelled
LiteLLM Rust DB / db-queries (push) Has been cancelled

This commit is contained in:
Mateo Wang 2026-11-08 07:03:36 +00:00 • committed by GitHub
parent cb7c3a65c6
commit 7ea4028625
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 166 additions and 40 deletions

View file

@ -182,7 +182,7 @@ class ManagedObjectCreatorAttribution:
team_id: Final = getattr(job, "team_id", None)
rows: Final = _CreatorRows(
key=await self._live_key_row(api_key, job.created_by),
team=await self._live_team_row(team_id),
team=await self._live_team_row(team_id, job.created_at),
user=await self._user_row(object_id, _creator_user_id(job.created_by)),
)
return ResponseCreator(
@ -269,10 +269,15 @@ class ManagedObjectCreatorAttribution:
verbose_proxy_logger.error(f"{self._poller_name}: could not look up team {team_id} for {object_id}: {e}")
return None
async def _live_team_row(self, team_id: str | None) -> prisma_models.LiteLLM_TeamTable | None:
async def _live_team_row(
self, team_id: str | None, created_before: datetime | None = None
) -> prisma_models.LiteLLM_TeamTable | None:
if not team_id:
return None
return await _team_table(self._prisma_client).find_unique(where={"team_id": team_id})
team: Final = await _team_table(self._prisma_client).find_unique(where={"team_id": team_id})
if team is not None and created_before is not None and team.created_at > created_before:
return None
return team
def _response_creator_metadata(job: ManagedObjectRow, rows: _CreatorRows) -> Mapping[str, object]:

View file

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.url_utils import encode_url_path_segment, join_a
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.types.llms.bedrock import AWS_AUTH_PARAM_KEYS
from litellm.types.llms.bedrock_invoke_agents import (
InvokeAgentChunkPayload,
InvokeAgentEvent,
@ -159,7 +160,7 @@ class AmazonInvokeAgentConfig(BaseConfig, BaseAWSLLM):
return {
"inputText": query,
"enableTrace": True,
**optional_params,
**{key: value for key, value in optional_params.items() if key not in AWS_AUTH_PARAM_KEYS},
}
def _parse_aws_event_stream(self, raw_content: bytes) -> InvokeAgentEventList:

View file

@ -108,7 +108,14 @@ def _bridge_request_data(
if key in data and key in agent_kwargs
}
return _with_headers(
{**agent_kwargs, **data, **layered, **pinned, "model": bridge_model_name(litellm_params)},
{
**agent_kwargs,
**data,
**layered,
**pinned,
"model": bridge_model_name(litellm_params),
"custom_llm_provider": agent_kwargs.get("custom_llm_provider"),
},
extra_headers,
)

View file

@ -4969,11 +4969,9 @@ def _owning_team_names_for_team_deployment(
if llm_router is None or team_id is None or not model.startswith(f"model_name_{team_id}_"):
return ()
deployment: Final = llm_router.registry().deployments(model, source="first_in_group")
public_name: Final = (
deployment.model_info.team_public_model_name
if deployment is not None and deployment.model_info.team_id == team_id
else None
)
if deployment is None or deployment.model_info.team_id != team_id:
return ()
public_name: Final = deployment.model_info.team_public_model_name
legacy_aliases: Final = tuple(alias for alias, target in (team_model_aliases or {}).items() if target == model)
return (*((public_name,) if public_name else ()), *legacy_aliases)

View file

@ -2124,7 +2124,7 @@ def _extract_models_from_managed_resource_id(
decoded_video: Final = decode_video_id_with_provider(resource_id)
_append_model_candidates(
candidates=candidates,
value=_video_access_group(decoded_video, llm_router) or decoded_video.get("model_id"),
value=video_access_group(decoded_video, llm_router) or decoded_video.get("model_id"),
)
else:
model_id = decode_character_id_with_provider(resource_id).get("model_id")
@ -2138,7 +2138,9 @@ def _extract_models_from_managed_resource_id(
return _dedupe_model_candidates(candidates)
def _video_access_group(decoded_video: DecodedVideoId, llm_router: Router | None) -> str | None:
def video_access_group(
decoded_video: DecodedVideoId, llm_router: Router | None, *, resolved_model: str | None = None
) -> str | None:
"""
The group a key must be allowed to call to use a video: the creating deployment's group, or, while router
fallbacks skip model access checks, the group whose router fallback created it.
@ -2146,7 +2148,9 @@ def _video_access_group(decoded_video: DecodedVideoId, llm_router: Router | None
from litellm.proxy.auth.fallback_model_access import router_fallback_access_check
from litellm.proxy.video_endpoints.utils import video_model_group
creating_group: Final = video_model_group(decoded_video, llm_router)
creating_group: Final = (
resolved_model if resolved_model is not None else video_model_group(decoded_video, llm_router)
)
requested_model: Final = decoded_video.get("requested_model")
if llm_router is None or creating_group is None or requested_model is None:
return creating_group

View file

@ -16,6 +16,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import as_str_mapping
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.managed_resources.isolation import resolve_resource_owner_id
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
@ -276,7 +277,12 @@ class ResponsesIDSecurity(CustomLogger):
):
return True
if response_id_user_id and response_id_user_id != user_api_key_dict.user_id:
if not response_id_user_id and not response_id_team_id:
if general_settings.get("disable_responses_id_security", False):
return True
raise HTTPException(status_code=403, detail="Forbidden. This response id has no associated owner")
if response_id_user_id and response_id_user_id != resolve_resource_owner_id(user_api_key_dict):
if general_settings.get("disable_responses_id_security", False):
verbose_proxy_logger.debug(
"Responses ID Security is disabled. User %s is accessing response id %s which is not associated with them.",
@ -394,7 +400,7 @@ class ResponsesIDSecurity(CustomLogger):
managed_id: Final = SpecialEnums.LITELLM_MANAGED_RESPONSE_API_RESPONSE_ID_COMPLETE_STR.value.format(
original_id,
user_api_key_dict.user_id or "",
resolve_resource_owner_id(user_api_key_dict) or "",
user_api_key_dict.team_id or "",
)
encrypted_id: Final = f"resp_{encrypt_value_helper(value=managed_id)}"

View file

@ -180,6 +180,9 @@ class TeamMemberPermissionChecks:
caller_team_role: Final = get_caller_team_role(team_table=team_table, user_api_key_dict=user_api_key_dict)
if not set(access_group_ids).issubset(team_table.access_group_ids or []):
raise HTTPException(status_code=403, detail="Access groups must be assigned to the key's team first")
# Team admins always bypass (consistent with other member-permission checks).
if caller_team_role == "admin":
return

View file

@ -16908,7 +16908,7 @@ async def _get_caller_byok_team_scope(
must have `model_info.team_id` ∈ that set, otherwise they belong to a
team the caller is not a member of and must be dropped.
"""
if user_api_key_dict is None or prisma_client is None:
if user_api_key_dict is None:
return None
if user_api_key_dict.user_role in (
LitellmUserRoles.PROXY_ADMIN,
@ -16917,7 +16917,7 @@ async def _get_caller_byok_team_scope(
return None
key_team_scope: Final[set[str]] = {user_api_key_dict.team_id} if user_api_key_dict.team_id else set()
user_id: Final = user_api_key_dict.user_id
if user_id is None:
if user_id is None or prisma_client is None:
return key_team_scope
try:
user_row: Final = await UserRepository(prisma_client).table.find_unique(where={"user_id": user_id})
@ -17599,7 +17599,7 @@ async def _find_model_by_id(
llm_router: Router | None,
prisma_client: PrismaClient | None,
proxy_config: "ProxyConfig",
) -> tuple[list, int | None]:
) -> tuple[Sequence[dict[str, Any]], int | None]:
"""Find a model by its ID and optionally filter by search term."""
found_model = None
@ -17796,18 +17796,32 @@ async def model_info_v2(
# Load existing config
await proxy_config.get_config()
allowed_team_ids: Final = await _get_caller_byok_team_scope(user_api_key_dict, prisma_client)
# If modelId is provided, search for the specific model
if modelId is not None:
all_models, search_total_count = await _find_model_by_id(
matched_models: Final = await _find_model_by_id(
model_id=modelId,
search=search,
llm_router=llm_router,
prisma_client=prisma_client,
proxy_config=proxy_config,
)
all_models, search_total_count = (
[
m
for m in matched_models[0]
if not _byok_row_outside_caller_teams(m.get("model_info", {}), allowed_team_ids)
],
matched_models[1],
)
else:
# Normal flow when modelId is not provided
all_models = copy.deepcopy(llm_router.registry().all())
all_models = [
m
for m in copy.deepcopy(llm_router.registry().all())
if not _byok_row_outside_caller_teams(m.get("model_info", {}), allowed_team_ids)
]
if user_model is not None:
# if user does not use a config.yaml, https://github.com/BerriAI/litellm/issues/2061
@ -18599,6 +18613,16 @@ async def model_info_v1(
status_code=400,
detail={"error": f"Model id = {litellm_model_id} not found on litellm proxy"},
)
if _byok_row_outside_caller_teams(
deployment_info.model_info.model_dump(),
await _get_caller_byok_team_scope(user_api_key_dict, prisma_client),
):
if include_team_models:
return _model_info_json_response([])
raise HTTPException(
status_code=400,
detail={"error": f"Model id = {litellm_model_id} not found on litellm proxy"},
)
_deployment_info_dict = _get_proxy_model_info(model=deployment_info.model_dump(exclude_none=True))
single_model_list: list[dict] = [_deployment_info_dict]
if prisma_client is not None:

View file

@ -22,13 +22,13 @@ from litellm.proxy.common_utils.openai_endpoint_utils import (
from litellm.proxy.image_endpoints.endpoints import batch_to_bytesio
from litellm.proxy.video_endpoints.utils import (
VideoRequestRoute,
authorized_video_model,
encode_character_id_in_response,
encode_video_id_in_response,
extract_model_from_target_model_names,
fallback_requested_model,
get_custom_provider_from_data,
routable_model_name,
video_model_group,
video_reference_to_id,
)
from litellm.types.videos.utils import (
@ -283,7 +283,7 @@ async def video_status(
if custom_llm_provider:
data["custom_llm_provider"] = custom_llm_provider
resolved_model: Final = video_model_group(decoded, llm_router)
resolved_model: Final = await authorized_video_model(decoded, llm_router, user_api_key_dict)
if resolved_model:
data["model"] = resolved_model
@ -384,7 +384,7 @@ async def video_content(
if custom_llm_provider:
data["custom_llm_provider"] = custom_llm_provider
resolved_model: Final = video_model_group(decoded, llm_router)
resolved_model: Final = await authorized_video_model(decoded, llm_router, user_api_key_dict)
if resolved_model:
data["model"] = resolved_model
# Process request using ProxyBaseLLMRequestProcessing
@ -490,7 +490,7 @@ async def video_remix(
if custom_llm_provider:
data["custom_llm_provider"] = custom_llm_provider
resolved_model: Final = video_model_group(decoded, llm_router)
resolved_model: Final = await authorized_video_model(decoded, llm_router, user_api_key_dict)
if resolved_model:
data["model"] = resolved_model
@ -814,7 +814,7 @@ async def video_edit(
)
data["custom_llm_provider"] = custom_llm_provider
resolved_model: Final = video_model_group(decoded, llm_router)
resolved_model: Final = await authorized_video_model(decoded, llm_router, user_api_key_dict)
if resolved_model:
data["model"] = resolved_model
@ -917,7 +917,7 @@ async def video_extension(
)
data["custom_llm_provider"] = custom_llm_provider
resolved_model: Final = video_model_group(decoded, llm_router)
resolved_model: Final = await authorized_video_model(decoded, llm_router, user_api_key_dict)
if resolved_model:
data["model"] = resolved_model

View file

@ -3,6 +3,9 @@ from typing import TYPE_CHECKING, Any, Final
import orjson
from pydantic import BaseModel, ConfigDict, TypeAdapter
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_utils import video_access_group
from litellm.proxy.openai_files_endpoints.common_utils import authorize_model_for_key
from litellm.types.videos.main import DecodedVideoId, VideoObject
from litellm.types.videos.utils import (
decode_video_id_with_provider,
@ -122,6 +125,20 @@ def video_model_group(decoded: DecodedVideoId, llm_router: "Router | None") -> s
return creating_group
async def authorized_video_model(
decoded: DecodedVideoId, llm_router: "Router | None", user_api_key_dict: UserAPIKeyAuth
) -> str | None:
model: Final = video_model_group(decoded, llm_router)
if model is None:
return None
await authorize_model_for_key(
model_id=video_access_group(decoded, llm_router, resolved_model=model) or model,
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
)
return model
def fallback_requested_model(requested_model: str | None, response: object, llm_router: "Router | None") -> str | None:
"""The group a create asked for when one of that group's router fallbacks served it."""
from litellm.proxy.auth.auth_utils import router_fallback_model_names

View file

@ -647,12 +647,21 @@ class ResponsesSessionHandler:
SELECT 1
FROM matching_session
WHERE matching_session.session_id = logs.session_id
AND matching_session."user" IS NOT DISTINCT FROM logs."user"
AND matching_session.team_id IS NOT DISTINCT FROM logs.team_id
AND (
matching_session."user" = logs."user"
OR (matching_session."user" IS NULL AND logs."user" IS NULL)
)
AND (
matching_session.team_id = logs.team_id
OR (matching_session.team_id IS NULL AND logs.team_id IS NULL)
)
AND (
COALESCE(matching_session."user", '') <> ''
OR COALESCE(matching_session.team_id, '') <> ''
OR matching_session.api_key = logs.api_key
OR (
COALESCE(matching_session.api_key, '') <> ''
AND matching_session.api_key = logs.api_key
)
)
)
ORDER BY "endTime" ASC;

View file

@ -91,6 +91,7 @@ from litellm.litellm_core_utils.allowed_tools_tool_choice import (
)
from litellm.litellm_core_utils.attempt_settlement import mark_attempt_answered
from litellm.litellm_core_utils.core_helpers import (
as_str_mapping,
bind_budget_reservation_to_callbacks,
has_attributes,
iter_items,
@ -5380,18 +5381,30 @@ def pre_process_non_default_params(
return non_default_params
def remove_sensitive_keys_from_dict(d: dict) -> dict:
def remove_sensitive_keys_from_dict(d: Mapping[str, object]) -> dict[str, object]:
"""
Remove sensitive keys from a dictionary
"""
sensitive_key_phrases: Final = ["key", "secret", "access", "credential"]
remove_keys: Final = []
for key in d:
if any(phrase in key.lower() for phrase in sensitive_key_phrases):
remove_keys.append(key)
for key in remove_keys:
d.pop(key)
return d
sensitive_key_phrases: Final = ("key", "secret", "access", "credential")
result: Final = dict[str, object]()
pending: Final = [(d, result)]
while pending:
source, target = pending.pop()
for key, value in source.items():
if (
any(phrase in key.lower() for phrase in sensitive_key_phrases)
or key.lower() == "token"
or key.lower().endswith("_token")
):
continue
mapping = as_str_mapping(value) if isinstance(value, dict) else None
if mapping is not None:
nested = dict[str, object]()
target[key] = nested
pending.append((mapping, nested))
else:
target[key] = value
return result
_TOOL_LIST: Final = TypeAdapter(list[object])

View file

@ -67,6 +67,7 @@ class _StoredResponse:
id: str
unified_object_id: str
status: str
created_at: datetime = datetime(2026, 1, 2)
model_object_id: str = ""
file_purpose: str = "response"
file_object: Mapping[str, object] = dataclasses.field(default_factory=lambda: {"model": "gpt-5"})
@ -2081,7 +2082,14 @@ def _key_row(metadata: Mapping[str, object]) -> object:
def _team_row(metadata: Mapping[str, object]) -> object:
from types import SimpleNamespace
return SimpleNamespace(team_alias=None, organization_id=None, spend=0.0, max_budget=None, metadata=dict(metadata))
return SimpleNamespace(
team_alias=None,
organization_id=None,
spend=0.0,
max_budget=None,
metadata=dict(metadata),
created_at=datetime(2026, 1, 1),
)
def _langfuse_callback_settings(callback_vars: Mapping[str, object]) -> Mapping[str, object]:

View file

@ -271,6 +271,7 @@ class TestEnforceMemberCanAssignAccessGroups:
team = MagicMock()
team.team_id = team_id
team.team_member_permissions = team_member_permissions
team.access_group_ids = ["ag-1"]
return team
def test_no_access_group_ids_is_noop(self, monkeypatch):

View file

@ -20,8 +20,10 @@ import pytest
from litellm.router import Router
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.videos.main import DecodedVideoId
from litellm.proxy.video_endpoints.utils import (
authorized_video_model,
encode_character_id_in_response,
encode_video_id_in_response,
extract_model_from_target_model_names,
@ -548,6 +550,33 @@ def _wildcard_router() -> Router:
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("enforce_fallback_model_access", "allowed_model"),
[(False, "sora-pair"), (True, "sora-backup")],
)
async def test_authorized_video_model_keeps_fallback_routing_after_the_key_check(
monkeypatch: pytest.MonkeyPatch, enforce_fallback_model_access: bool, allowed_model: str
) -> None:
monkeypatch.setattr(
"litellm.proxy.proxy_server.general_settings",
{"enforce_fallback_model_access": enforce_fallback_model_access},
)
decoded: Final = decode_video_id_with_provider(
encode_video_id_with_provider(
"video_raw", "azure", "sora-2", deployment_id="dep-sora-backup", requested_model="sora-pair"
)
)
token: Final = UserAPIKeyAuth(api_key="hashed", models=[allowed_model])
assert await authorized_video_model(decoded, _fallback_router(), token) == "sora-backup"
@pytest.mark.asyncio
async def test_authorized_video_model_without_a_router_has_no_routing_model() -> None:
assert await authorized_video_model(_decoded("sora-2", "dep-sora-backup"), None, UserAPIKeyAuth()) is None
@pytest.mark.parametrize(
("requested_model", "answering_deployment", "expected"),
[

View file

@ -17,6 +17,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.gemini.videos.transformation import GeminiVideoConfig
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.videos.main import VideoObject, VideoResponse
from litellm.videos import main as videos_main
from litellm.videos.main import (
@ -2232,7 +2233,7 @@ def video_proxy_test_client():
app = FastAPI()
app.include_router(video_router)
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key="sk-9876")
return TestClient(app)