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:
devin-ai-integration[bot] 2026-09-21 20:04:30 -07:00 • committed by GitHub
parent 0fd1c191ca
commit 3252852b0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 116 additions and 57 deletions

View file

@ -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

View file

@ -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(

View file

@ -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."""

View file

@ -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)
# ---------------------------------------------------------------------------