mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
fix(auth): fail closed when the JWT single-team fallback or compact editor membership read hits a DB outage (#42344)
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
0fd1c191ca
commit
3252852b0f
4 changed files with 116 additions and 57 deletions
|
|
@ -208,9 +208,12 @@ async def _check_summary_model_access(
|
|||
(``ProxyException`` from ``_can_object_call_model`` / ``can_*_model``).
|
||||
Unexpected errors during an access check fail closed but are logged
|
||||
separately so operators can distinguish them from a real access-denied
|
||||
response. DB-lookup failures (object missing from cache or DB) skip the
|
||||
corresponding scope — matching ``common_checks``, which only enforces a
|
||||
scope when its backing object can be loaded.
|
||||
response. User and project lookup failures (object missing from cache or
|
||||
DB) skip the corresponding scope — matching ``common_checks``, which only
|
||||
enforces a scope when its backing object can be loaded. A failed team
|
||||
membership read (a database outage) fails closed instead, since a member
|
||||
whose limits cannot be read must not have the summary model invoked with
|
||||
those limits dropped.
|
||||
"""
|
||||
if user_api_key_auth is None:
|
||||
return True
|
||||
|
|
@ -346,13 +349,12 @@ async def _check_summary_model_access(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.debug(
|
||||
"compact_20260112: team membership lookup failed for "
|
||||
"summary_model=%s access check; skipping member-level scope: %s",
|
||||
verbose_logger.warning(
|
||||
"compact_20260112: team membership lookup failed for summary_model=%s access check; denying access: %s",
|
||||
summary_model,
|
||||
e,
|
||||
)
|
||||
team_membership = None
|
||||
return False
|
||||
member_allowed_models: Final = (
|
||||
team_membership.litellm_budget_table.allowed_models
|
||||
if team_membership is not None and team_membership.litellm_budget_table is not None
|
||||
|
|
|
|||
|
|
@ -2099,8 +2099,11 @@ class JWTAuthManager:
|
|||
spend / metadata can be attributed correctly.
|
||||
|
||||
Returns (team_id, team_object, team_membership_object).
|
||||
Any DB error is debug-logged and the tuple is (None, None, None) — no
|
||||
exception ever propagates from this helper.
|
||||
A team that cannot be loaded (HTTPException from get_team_object) is
|
||||
debug-logged and the tuple is (None, None, None), the same as the DB
|
||||
team fallback. A failed membership read propagates, so a database
|
||||
outage surfaces as the 503 the rest of auth answers with instead of
|
||||
serving the request with the member's limits dropped.
|
||||
"""
|
||||
if user_object is None or not user_object.teams or len(user_object.teams) != 1:
|
||||
return None, None, None
|
||||
|
|
@ -2115,28 +2118,28 @@ class JWTAuthManager:
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
team_id_upsert=team_id_upsert,
|
||||
)
|
||||
if team_row is None:
|
||||
return None, None, None
|
||||
|
||||
if not user_id:
|
||||
return _tid, team_row, None
|
||||
|
||||
team_membership: Final = await get_team_membership(
|
||||
user_id=user_id,
|
||||
team_id=_tid,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
return _tid, team_row, team_membership
|
||||
except Exception:
|
||||
except HTTPException:
|
||||
verbose_proxy_logger.debug(
|
||||
"JWT single-team fallback error, skipping. team_id=%s",
|
||||
"JWT single-team fallback: team could not be loaded, skipping. team_id=%s",
|
||||
_tid,
|
||||
exc_info=True,
|
||||
)
|
||||
return None, None, None
|
||||
if team_row is None:
|
||||
return None, None, None
|
||||
|
||||
if not user_id:
|
||||
return _tid, team_row, None
|
||||
|
||||
team_membership: Final = await get_team_membership(
|
||||
user_id=user_id,
|
||||
team_id=_tid,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
return _tid, team_row, team_membership
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_db_team_fallback(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import json
|
|||
from typing import Any, Dict, List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
|
@ -1507,6 +1508,55 @@ async def test_summary_model_denied_when_team_member_scope_excludes_it():
|
|||
assert result.applied_edits[0].get("error") == "summary_model_access_denied"
|
||||
|
||||
|
||||
async def test_summary_model_denied_when_team_membership_read_hits_a_db_outage():
|
||||
"""A member-level scope that cannot be read fails closed: the summary
|
||||
model is not invoked while the membership row is unreachable."""
|
||||
messages = _simple_messages()
|
||||
mock_call = AsyncMock(return_value=_make_mock_response("<summary>x</summary>"))
|
||||
|
||||
auth = _fake_user_api_key_auth(key_models=["all-proxy-models"], team_id="team-outage")
|
||||
auth.user_id = "user-outage"
|
||||
|
||||
class _UnreachableMembershipPrisma:
|
||||
class db:
|
||||
class litellm_teammembership:
|
||||
@staticmethod
|
||||
async def find_unique(where: dict[str, dict[str, str]], include: dict[str, bool]) -> None:
|
||||
raise httpx.ConnectError("All connection attempts failed")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.llms.anthropic.experimental_pass_through.context_management.editors.compact._read_summary_model_setting",
|
||||
return_value="claude-haiku-4-5",
|
||||
),
|
||||
patch("litellm.token_counter", return_value=200_000),
|
||||
patch(
|
||||
"litellm.llms.anthropic.experimental_pass_through.context_management.editors.compact._call_summary_model",
|
||||
mock_call,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks.get_user_object",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks.get_project_object",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", _UnreachableMembershipPrisma()),
|
||||
):
|
||||
result = await apply_compact_20260112(
|
||||
model=MODEL,
|
||||
messages=messages,
|
||||
tools=None,
|
||||
system=None,
|
||||
edit_spec=_EDIT_SPEC_DEFAULT,
|
||||
user_api_key_auth=auth,
|
||||
)
|
||||
|
||||
mock_call.assert_not_awaited()
|
||||
assert result.applied_edits[0].get("error") == "summary_model_access_denied"
|
||||
|
||||
|
||||
async def test_summary_model_denied_when_key_over_model_budget():
|
||||
"""A caller whose per-model budget for the summary model is exhausted cannot
|
||||
trigger the summary call via compaction."""
|
||||
|
|
|
|||
|
|
@ -3350,15 +3350,26 @@ async def test_auth_builder_single_team_db_fallback_when_jwt_has_no_team(
|
|||
mock_get_membership.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_builder_single_team_fallback_membership_error_skips_no_raise():
|
||||
"""
|
||||
get_team_object succeeds but get_team_membership raises — do not set team; no exception.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
class _UnreachableMembershipPrisma:
|
||||
class db:
|
||||
class litellm_teammembership:
|
||||
@staticmethod
|
||||
async def find_unique(where: dict[str, dict[str, str]], include: dict[str, bool]) -> None:
|
||||
raise httpx.ConnectError("All connection attempts failed")
|
||||
|
||||
user_id = "u_mem_fail"
|
||||
team_id_val = "team_mem_fail"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_builder_single_team_fallback_membership_outage_raises_instead_of_dropping_the_team():
|
||||
"""
|
||||
get_team_object succeeds but the membership read hits a database outage: the
|
||||
outage propagates (auth maps it to 503) instead of the team being dropped.
|
||||
"""
|
||||
from litellm.proxy.auth.auth_exception_handler import _as_proxy_exception
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
user_id = "u_mem_outage"
|
||||
team_id_val = "team_mem_outage"
|
||||
user_object = LiteLLM_UserTable(
|
||||
user_id=user_id,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
|
|
@ -3367,6 +3378,7 @@ async def test_auth_builder_single_team_fallback_membership_error_skips_no_raise
|
|||
team_table = LiteLLM_TeamTable(team_id=team_id_val)
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth()
|
||||
cache = UserApiKeyCache()
|
||||
|
||||
with (
|
||||
patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt,
|
||||
|
|
@ -3416,34 +3428,26 @@ async def test_auth_builder_single_team_fallback_membership_error_skips_no_raise
|
|||
"litellm.proxy.auth.handle_jwt.get_team_object",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_team,
|
||||
patch(
|
||||
"litellm.proxy.auth.handle_jwt.get_team_membership",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_membership,
|
||||
):
|
||||
mock_auth_jwt.return_value = {"sub": user_id, "scope": ""}
|
||||
mock_get_team.return_value = team_table
|
||||
mock_get_membership.side_effect = HTTPException(
|
||||
status_code=500, detail="membership lookup failed"
|
||||
)
|
||||
|
||||
result = await JWTAuthManager.auth_builder(
|
||||
api_key="test_jwt_token",
|
||||
jwt_handler=jwt_handler,
|
||||
request_data={"model": "gpt-4"},
|
||||
general_settings={"enforce_rbac": False},
|
||||
route="/chat/completions",
|
||||
prisma_client=None,
|
||||
user_api_key_cache=None,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
with pytest.raises(httpx.ConnectError) as raised:
|
||||
await JWTAuthManager.auth_builder(
|
||||
api_key="test_jwt_token",
|
||||
jwt_handler=jwt_handler,
|
||||
request_data={"model": "gpt-4"},
|
||||
general_settings={"enforce_rbac": False},
|
||||
route="/chat/completions",
|
||||
prisma_client=_UnreachableMembershipPrisma(),
|
||||
user_api_key_cache=cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
|
||||
)
|
||||
|
||||
assert result["team_id"] is None
|
||||
assert result["team_object"] is None
|
||||
assert result["team_membership"] is None
|
||||
mock_get_team.assert_called()
|
||||
mock_get_membership.assert_called_once()
|
||||
mock_get_team.assert_called()
|
||||
surfaced = _as_proxy_exception(raised.value)
|
||||
assert (surfaced.code, surfaced.type) == ("503", ProxyErrorTypes.no_db_connection)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue