mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(proxy): populate user_email on UserAPIKeyAuth for JWT auth (#34174)
JWT auth built UserAPIKeyAuth without user_email even though the resolved user row and the JWT email claim were both available, so the user_email label on Prometheus metrics and user_api_key_user_email in StandardLogging/SpendLogs metadata were always None for JWT traffic. Plumb user_email through JWTAuthBuilderResult: auth_builder returns the user row email when set, falling back to the user_email_jwt_field claim (covers the scope-based proxy-admin path where no user row is loaded). The JWT branch now stamps it on the proxy-admin return, the standard valid_token, and the auto-registered virtual key object. Resolves LIT-4238
This commit is contained in:
parent
02746eb122
commit
e17f3b6e1a
6 changed files with 220 additions and 1 deletions
|
|
@ -4052,6 +4052,7 @@ class JWTAuthBuilderResult(TypedDict):
|
|||
token: str
|
||||
team_id: Optional[str]
|
||||
user_id: Optional[str]
|
||||
user_email: str | None
|
||||
end_user_id: Optional[str]
|
||||
org_id: Optional[str]
|
||||
team_membership: Optional[LiteLLM_TeamMembership]
|
||||
|
|
|
|||
|
|
@ -1155,6 +1155,7 @@ class JWTAuthManager:
|
|||
org_id: Optional[str],
|
||||
api_key: str,
|
||||
jwt_valid_token: Optional[dict] = None,
|
||||
user_email: str | None = None,
|
||||
) -> Optional[JWTAuthBuilderResult]:
|
||||
"""Check admin status and route access permissions"""
|
||||
if not jwt_handler.is_admin(scopes=scopes):
|
||||
|
|
@ -1179,6 +1180,7 @@ class JWTAuthManager:
|
|||
token=api_key,
|
||||
team_id=None,
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
end_user_id=None,
|
||||
org_id=org_id,
|
||||
team_membership=None,
|
||||
|
|
@ -2068,7 +2070,7 @@ class JWTAuthManager:
|
|||
|
||||
# Check admin access
|
||||
admin_result = await JWTAuthManager.check_admin_access(
|
||||
jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token
|
||||
jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token, user_email=user_email
|
||||
)
|
||||
if admin_result:
|
||||
await JWTAuthManager._attach_team_from_header_for_admin(
|
||||
|
|
@ -2303,6 +2305,7 @@ class JWTAuthManager:
|
|||
team_id=team_id,
|
||||
team_object=team_object,
|
||||
user_id=user_id,
|
||||
user_email=(user_object.user_email if user_object is not None and user_object.user_email else user_email),
|
||||
user_object=user_object,
|
||||
org_id=resolved_org_id, # Use resolved org_id (from alias lookup if applicable)
|
||||
org_object=org_object,
|
||||
|
|
|
|||
|
|
@ -1255,6 +1255,7 @@ async def _user_api_key_auth_builder(
|
|||
team_id = result["team_id"]
|
||||
team_object = result["team_object"]
|
||||
user_id = result["user_id"]
|
||||
user_email = result["user_email"]
|
||||
user_object = result["user_object"]
|
||||
end_user_id = result["end_user_id"]
|
||||
org_id = result["org_id"]
|
||||
|
|
@ -1279,6 +1280,7 @@ async def _user_api_key_auth_builder(
|
|||
api_key=None,
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
team_id=team_id,
|
||||
team_alias=(team_object.team_alias if team_object is not None else None),
|
||||
team_tpm_limit=(team_object.tpm_limit if team_object is not None else None),
|
||||
|
|
@ -1304,6 +1306,7 @@ async def _user_api_key_auth_builder(
|
|||
else LitellmUserRoles.INTERNAL_USER
|
||||
),
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
org_id=org_id,
|
||||
parent_otel_span=parent_otel_span,
|
||||
end_user_id=end_user_id,
|
||||
|
|
@ -1345,6 +1348,7 @@ async def _user_api_key_auth_builder(
|
|||
)
|
||||
if auto_registered is not None:
|
||||
auto_registered.jwt_claims = jwt_claims
|
||||
auto_registered.user_email = user_email
|
||||
valid_token = auto_registered
|
||||
api_key = valid_token.token or ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1115,6 +1115,7 @@ async def test_jwt_non_admin_team_route_access(monkeypatch):
|
|||
"team_id": None,
|
||||
"team_object": None,
|
||||
"user_id": None,
|
||||
"user_email": None,
|
||||
"user_object": None,
|
||||
"org_id": None,
|
||||
"org_object": None,
|
||||
|
|
|
|||
|
|
@ -458,6 +458,118 @@ async def test_auth_builder_non_proxy_admin_user_role():
|
|||
assert result["user_id"] == "test_user_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"row_email,expected_email",
|
||||
[
|
||||
("row@example.com", "row@example.com"),
|
||||
(None, "claim@example.com"),
|
||||
("", "claim@example.com"),
|
||||
],
|
||||
)
|
||||
async def test_auth_builder_result_includes_user_email(row_email, expected_email):
|
||||
"""LIT-4238: auth_builder must return user_email (user row wins, JWT claim
|
||||
is the fallback) so the auth object and metrics get the email."""
|
||||
api_key = "test_jwt_token"
|
||||
request_data = {"model": "gpt-4"}
|
||||
general_settings = {"enforce_rbac": False}
|
||||
route = "/chat/completions"
|
||||
|
||||
user_object = LiteLLM_UserTable(
|
||||
user_id="test_user_1",
|
||||
user_email=row_email,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth()
|
||||
|
||||
with (
|
||||
patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt,
|
||||
patch.object(JWTAuthManager, "check_rbac_role", new_callable=AsyncMock),
|
||||
patch.object(jwt_handler, "get_rbac_role", return_value=None),
|
||||
patch.object(jwt_handler, "get_scopes", return_value=[]),
|
||||
patch.object(jwt_handler, "get_object_id", return_value=None),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"get_user_info",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("test_user_1", "claim@example.com", True),
|
||||
),
|
||||
patch.object(jwt_handler, "get_org_id", return_value=None),
|
||||
patch.object(jwt_handler, "get_end_user_id", return_value=None),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"check_admin_access",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
) as mock_check_admin,
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"find_and_validate_specific_team_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch.object(JWTAuthManager, "get_all_team_ids", return_value=set()),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"find_team_with_model_access",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"get_objects",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(user_object, None, None, None, user_object.user_id),
|
||||
),
|
||||
patch.object(JWTAuthManager, "map_user_to_teams", new_callable=AsyncMock),
|
||||
patch.object(JWTAuthManager, "validate_object_id", return_value=True),
|
||||
):
|
||||
mock_auth_jwt.return_value = {"sub": "test_user_1", "scope": ""}
|
||||
|
||||
result = await JWTAuthManager.auth_builder(
|
||||
api_key=api_key,
|
||||
jwt_handler=jwt_handler,
|
||||
request_data=request_data,
|
||||
general_settings=general_settings,
|
||||
route=route,
|
||||
prisma_client=None,
|
||||
user_api_key_cache=None,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
assert result["user_email"] == expected_email
|
||||
assert mock_check_admin.call_args.kwargs["user_email"] == "claim@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_admin_access_result_includes_user_email():
|
||||
"""LIT-4238: the scope-based admin path has no user row, so the JWT claim
|
||||
email must ride the JWTAuthBuilderResult."""
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(
|
||||
admin_jwt_scope="litellm_proxy_admin",
|
||||
admin_allowed_routes=["/chat/completions"],
|
||||
)
|
||||
|
||||
result = await JWTAuthManager.check_admin_access(
|
||||
jwt_handler=jwt_handler,
|
||||
scopes=["litellm_proxy_admin"],
|
||||
route="/chat/completions",
|
||||
user_id="admin-user",
|
||||
user_email="admin@example.com",
|
||||
org_id=None,
|
||||
api_key="test_jwt_token",
|
||||
jwt_valid_token={"sub": "admin-user"},
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["is_proxy_admin"] is True
|
||||
assert result["user_email"] == "admin@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_role_and_teams():
|
||||
from unittest.mock import MagicMock
|
||||
|
|
|
|||
|
|
@ -1569,6 +1569,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "jwt-team",
|
||||
"user_id": "jwt-human-user",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -1643,6 +1644,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "validated-team",
|
||||
"user_id": "validated-user",
|
||||
"user_email": "validated@example.com",
|
||||
"end_user_id": "validated-end-user",
|
||||
"org_id": "validated-org",
|
||||
"team_membership": None,
|
||||
|
|
@ -1702,6 +1704,7 @@ class TestJWTOAuth2Coexistence:
|
|||
mock_auto_register.call_args.kwargs["end_user_id"] == "validated-end-user"
|
||||
)
|
||||
assert result.org_id == "validated-org"
|
||||
assert result.user_email == "validated@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_override_routes_matching_jwt_to_oauth2(self):
|
||||
|
|
@ -1788,6 +1791,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "jwt-team",
|
||||
"user_id": "jwt-user-no-override",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -1988,6 +1992,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "jwt-team",
|
||||
"user_id": "jwt-user-scope-mismatch",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -2296,6 +2301,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": None,
|
||||
"user_id": "jwt-admin-user",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -4255,6 +4261,98 @@ async def test_auth_does_not_rewrite_cached_key_object_back_into_cache():
|
|||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
class TestJWTAuthUserEmail:
|
||||
"""JWT auth must populate `UserAPIKeyAuth.user_email` (LIT-4238); it feeds
|
||||
the Prometheus `user_email` label and `user_api_key_user_email` in
|
||||
StandardLogging/SpendLogs metadata, which were always None for JWT traffic."""
|
||||
|
||||
def _jwt_request(self, jwt_token):
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.path = "/v1/chat/completions"
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
|
||||
mock_request.query_params = {}
|
||||
return mock_request
|
||||
|
||||
async def _run_jwt_auth(self, mock_jwt_result, jwt_token):
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"enable_jwt_auth": True},
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", None),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_jwt_result,
|
||||
),
|
||||
):
|
||||
litellm.proxy.proxy_server.jwt_handler.update_environment(
|
||||
prisma_client=None,
|
||||
user_api_key_cache=DualCache(),
|
||||
litellm_jwtauth=LiteLLM_JWTAuth(),
|
||||
)
|
||||
return await user_api_key_auth(
|
||||
request=self._jwt_request(jwt_token),
|
||||
api_key=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_auth_populates_user_email_on_valid_token(self):
|
||||
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
|
||||
mock_jwt_result = {
|
||||
"is_proxy_admin": False,
|
||||
"team_object": None,
|
||||
"user_object": LiteLLM_UserTable(
|
||||
user_id="jwt-human-user",
|
||||
user_email="row@example.com",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
),
|
||||
"end_user_object": None,
|
||||
"org_object": None,
|
||||
"token": jwt_token,
|
||||
"team_id": None,
|
||||
"user_id": "jwt-human-user",
|
||||
"user_email": "resolved@example.com",
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
"jwt_claims": {"sub": "user1"},
|
||||
}
|
||||
|
||||
result = await self._run_jwt_auth(mock_jwt_result, jwt_token)
|
||||
|
||||
assert result.user_id == "jwt-human-user"
|
||||
assert result.user_email == "resolved@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_auth_populates_user_email_on_proxy_admin(self):
|
||||
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
|
||||
mock_jwt_result = {
|
||||
"is_proxy_admin": True,
|
||||
"team_object": None,
|
||||
"user_object": None,
|
||||
"end_user_object": None,
|
||||
"org_object": None,
|
||||
"token": jwt_token,
|
||||
"team_id": None,
|
||||
"user_id": "jwt-admin-user",
|
||||
"user_email": "admin@example.com",
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
"jwt_claims": {"sub": "user1"},
|
||||
}
|
||||
|
||||
result = await self._run_jwt_auth(mock_jwt_result, jwt_token)
|
||||
|
||||
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
assert result.user_id == "jwt-admin-user"
|
||||
assert result.user_email == "admin@example.com"
|
||||
|
||||
|
||||
class TestCheckKeyModelBudgetWithFallback:
|
||||
"""`_check_key_model_budget_with_fallback` must reroute a request to the
|
||||
first configured `budget_fallbacks` entry still within its own budget,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue