From bf8df3ab022b81d54b9d8dcc3d97f8002d1bc3a5 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Thu, 3 Sep 2026 16:18:46 +0200
Subject: [PATCH 01/16] hibp support in password policy
---
litellm/proxy/_types.py | 11 ++
litellm/proxy/auth/password_policy.py | 68 +++++++++
.../internal_user_endpoints.py | 9 +-
litellm/proxy/proxy_server.py | 3 +-
litellm/types/llms/custom_http.py | 1 +
.../proxy/auth/test_onboarding.py | 140 +++++++++++++++++-
.../proxy/auth/test_password_policy.py | 134 +++++++++++++++++
.../test_internal_user_endpoints.py | 34 +++++
tests/test_litellm/proxy/test__types.py | 16 ++
ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +
10 files changed, 409 insertions(+), 9 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index b315a2beac9..9b38b12ab07 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -1808,6 +1808,17 @@ class NewUserRequest(GenerateRequestBase):
send_invite_email: bool | None = None
sso_user_id: str | None = None
organizations: list[str] | None = None
+ password: str | None = None
+
+ @field_validator("password")
+ @classmethod
+ def password_not_supported(cls, value: str | None) -> str | None:
+ if value is not None:
+ raise ValueError(
+ "password cannot be set via /user/new. Users set their own password through an "
+ "invitation link (POST /invitation/new)."
+ )
+ return value
class NewUserResponse(GenerateKeyResponse):
diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py
index ab7a565894a..80c3ebeedca 100644
--- a/litellm/proxy/auth/password_policy.py
+++ b/litellm/proxy/auth/password_policy.py
@@ -4,13 +4,26 @@ Applied at every path that persists a new or changed password for a DB-backed
user (``/user/update``, ``/user/bulk_update``, and the invitation onboarding
claim flow), so the strength bar is configured in one place instead of
per-endpoint.
+
+Also screens new passwords against known data breaches via the
+haveibeenpwned.com (HIBP) k-anonymity range API: only the first 5 characters
+of the password's SHA-1 hash ever leave the proxy, and the check fails open
+(allows the password) when HIBP is unreachable.
"""
+import hashlib
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final
+from litellm._logging import verbose_proxy_logger
+from litellm._version import version
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client
from litellm.proxy._types import ProxyErrorTypes, ProxyException
+from litellm.types.llms.custom_http import httpxSpecialProvider
+
+HIBP_RANGE_API_BASE: Final = "https://api.pwnedpasswords.com/range"
+HIBP_TIMEOUT_SECONDS: Final = 5.0
DEFAULT_MIN_LENGTH: Final = 12
MIN_ALLOWED_LENGTH: Final = 8
@@ -90,3 +103,58 @@ def validate_password_policy(password: str, general_settings: Mapping[str, objec
param="password",
code=400,
)
+
+
+def _hibp_client() -> AsyncHTTPHandler:
+ return get_async_httpx_client(
+ llm_provider=httpxSpecialProvider.PasswordBreachCheck,
+ params={"timeout": HIBP_TIMEOUT_SECONDS},
+ )
+
+
+def _is_suffix_in_range_response(response_body: str, hash_suffix: str) -> bool:
+ for line in response_body.upper().splitlines():
+ entry_suffix, _, count = line.strip().partition(":")
+ if entry_suffix == hash_suffix:
+ return int(count.strip() or "0") > 0
+ return False
+
+
+async def _is_password_breached(password: str, client: AsyncHTTPHandler) -> bool:
+ # usedforsecurity=False: SHA-1 is only a lookup key into the HIBP dataset, so no security property rests on it
+ sha1_hex: Final = hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+ try:
+ response: Final = await client.get(
+ f"{HIBP_RANGE_API_BASE}/{sha1_hex[:5]}",
+ headers={"Add-Padding": "true", "User-Agent": f"litellm-proxy/{version}"},
+ )
+ response.raise_for_status()
+ breached: Final = _is_suffix_in_range_response(response.text, sha1_hex[5:])
+ except Exception as e:
+ verbose_proxy_logger.warning("Breached-password check skipped, HIBP lookup failed: %s", e)
+ return False
+ return breached
+
+
+async def validate_password_not_breached(
+ password: str,
+ general_settings: Mapping[str, object],
+ client: AsyncHTTPHandler | None = None,
+) -> None:
+ """Raise ``ProxyException`` (400) if ``password`` appears in a known data breach.
+
+ Fails open: an unreachable or misbehaving HIBP allows the password."""
+ check_enabled: Final = general_settings.get("password_policy_check_breached_passwords", True) is not False
+ if not check_enabled:
+ return
+ if not await _is_password_breached(password, client if client is not None else _hibp_client()):
+ return
+ raise ProxyException(
+ message=(
+ "This password appears in known data breaches and cannot be used. "
+ "Please choose a different password."
+ ),
+ type=ProxyErrorTypes.validation_error,
+ param="password",
+ code=400,
+ )
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index e3efda507f6..f22bad70817 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -29,7 +29,7 @@ from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import get_team_object, get_user_object
-from litellm.proxy.auth.password_policy import validate_password_policy
+from litellm.proxy.auth.password_policy import validate_password_not_breached, validate_password_policy
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.user_api_key_cache import (
@@ -162,10 +162,11 @@ def _team_membership_table(
return team_membership_table
-def _hash_password_in_dict(data: dict, general_settings: Mapping[str, object]) -> None:
+async def _hash_password_in_dict(data: dict, general_settings: Mapping[str, object]) -> None:
"""Validate and hash password field in-place if present."""
if "password" in data and data["password"] is not None:
validate_password_policy(data["password"], general_settings)
+ await validate_password_not_breached(data["password"], general_settings)
data["password"] = hash_password(data["password"])
@@ -561,7 +562,7 @@ async def new_user(
# generate_key_helper_fn only forwards object_permission_id, so without this the entitlement
# the caller sent would be dropped on the floor.
data_json = await _set_object_permission(data_json=data_json, prisma_client=prisma_client)
- _hash_password_in_dict(data_json, general_settings)
+ data_json.pop("password", None) # always None: NewUserRequest.password_not_supported rejects any other value
teams = data.teams
if teams is None:
teams = check_if_default_team_set()
@@ -1449,7 +1450,7 @@ async def _update_single_user_helper(
data_json: Final[dict] = user_request.model_dump(exclude_unset=True)
non_default_values = _update_internal_user_params(data_json=data_json, data=user_request)
- _hash_password_in_dict(non_default_values, general_settings)
+ await _hash_password_in_dict(non_default_values, general_settings)
existing_user_row: BaseModel | None = None
if user_request.user_id:
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index d9f8e04ebda..d2b486b4410 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -331,7 +331,7 @@ from litellm.proxy.auth.model_checks import (
get_mcp_server_ids,
get_team_models,
)
-from litellm.proxy.auth.password_policy import validate_password_policy
+from litellm.proxy.auth.password_policy import validate_password_not_breached, validate_password_policy
from litellm.proxy.auth.user_api_key_auth import (
_fetch_global_spend_with_event_coordination,
user_api_key_auth,
@@ -16369,6 +16369,7 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
)
validate_password_policy(data.password, general_settings)
+ await validate_password_not_breached(data.password, general_settings)
hashed_pw: Final = hash_password(data.password)
current_time = litellm.utils.get_utc_datetime()
async with prisma_client.db.tx() as tx:
diff --git a/litellm/types/llms/custom_http.py b/litellm/types/llms/custom_http.py
index d80d7410aae..793893451df 100644
--- a/litellm/types/llms/custom_http.py
+++ b/litellm/types/llms/custom_http.py
@@ -31,6 +31,7 @@ class httpxSpecialProvider(str, Enum):
UI = "ui"
Sandbox = "sandbox"
ModelCostMap = "model_cost_map"
+ PasswordBreachCheck = "password_breach_check"
VerifyTypes = str | bool | ssl.SSLContext
diff --git a/tests/test_litellm/proxy/auth/test_onboarding.py b/tests/test_litellm/proxy/auth/test_onboarding.py
index 524b655b465..8939baedd50 100644
--- a/tests/test_litellm/proxy/auth/test_onboarding.py
+++ b/tests/test_litellm/proxy/auth/test_onboarding.py
@@ -8,15 +8,20 @@ Covers the security behavior of:
session key only after the password is written
"""
+import hashlib
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
import jwt
import pytest
+import respx
from fastapi import HTTPException
import litellm
-from litellm.proxy._types import InvitationClaim
+from litellm.proxy._types import InvitationClaim, ProxyException
+
+_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
# ---------------------------------------------------------------------------
# Helpers
@@ -386,7 +391,9 @@ async def test_claim_token_rejects_concurrent_reuse_before_password_write():
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
- patch("litellm.proxy.proxy_server.general_settings", {}),
+ patch( # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
new_callable=AsyncMock,
@@ -426,7 +433,9 @@ async def test_claim_token_sets_accepted_at_after_password_written():
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
- patch("litellm.proxy.proxy_server.general_settings", {}),
+ patch( # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
patch("litellm.proxy.proxy_server.premium_user", False),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
@@ -483,7 +492,9 @@ async def test_claim_token_rolls_back_invite_when_session_key_mint_fails():
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
- patch("litellm.proxy.proxy_server.general_settings", {}),
+ patch( # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
patch(
"litellm.proxy.proxy_server.generate_key_helper_fn",
new_callable=AsyncMock,
@@ -505,3 +516,124 @@ async def test_claim_token_rolls_back_invite_when_session_key_mint_fails():
}
assert rollback_kwargs["data"]["accepted_at"] is None
assert rollback_kwargs["data"]["is_accepted"] is False
+
+
+# ---------------------------------------------------------------------------
+# POST /onboarding/claim_token - password policy
+# ---------------------------------------------------------------------------
+
+
+def _hibp_url_for(password: str) -> str:
+ sha1 = hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+ return f"https://api.pwnedpasswords.com/range/{sha1[:5]}"
+
+
+def _hibp_suffix_for(password: str) -> str:
+ return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()[5:]
+
+
+@pytest.mark.asyncio
+async def test_claim_token_rejects_short_password_before_consuming_invite():
+ """Default policy requires 12 characters; the invite must stay claimable."""
+ from litellm.proxy.proxy_server import claim_onboarding_link
+
+ invite = _make_invite(is_accepted=False)
+ prisma = _make_prisma(invite, _make_user())
+ request = _make_claim_request(_make_onboarding_token())
+ data = InvitationClaim(
+ invitation_link="invite-abc",
+ user_id="user-123",
+ password="Sh0rt!pw",
+ )
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.master_key", "sk-test"), # test-quality-ok: same as above
+ patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: same as above
+ ):
+ with pytest.raises(ProxyException) as exc_info:
+ await claim_onboarding_link(data=data, request=request)
+
+ assert exc_info.value.code == "400"
+ assert "at least 12 characters" in exc_info.value.message
+ prisma.db.litellm_invitationlink.update_many.assert_not_called()
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_claim_token_rejects_breached_password_before_consuming_invite():
+ """A password found in the HIBP corpus must be rejected and never stored."""
+ from litellm.proxy.proxy_server import claim_onboarding_link
+
+ password = "P@ssword123456"
+ respx.get(_hibp_url_for(password)).mock(
+ return_value=httpx.Response(200, text=f"{_hibp_suffix_for(password)}:1387")
+ )
+
+ invite = _make_invite(is_accepted=False)
+ prisma = _make_prisma(invite, _make_user())
+ request = _make_claim_request(_make_onboarding_token())
+ data = InvitationClaim(
+ invitation_link="invite-abc",
+ user_id="user-123",
+ password=password,
+ )
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.master_key", "sk-test"), # test-quality-ok: same as above
+ patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: same as above
+ ):
+ with pytest.raises(ProxyException) as exc_info:
+ await claim_onboarding_link(data=data, request=request)
+
+ assert exc_info.value.code == "400"
+ assert "data breaches" in exc_info.value.message
+ prisma.db.litellm_invitationlink.update_many.assert_not_called()
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_claim_token_fails_open_when_hibp_unreachable():
+ """An HIBP outage must never block onboarding: the claim proceeds."""
+ from litellm.proxy.proxy_server import claim_onboarding_link
+
+ password = "NewP@ssw0rd-2026"
+ respx.get(_hibp_url_for(password)).mock(side_effect=httpx.ConnectError("no route to host"))
+
+ invite = _make_invite(is_accepted=False)
+ user = _make_user()
+ prisma = _make_prisma(invite, user)
+ request = _make_claim_request(_make_onboarding_token())
+ data = InvitationClaim(
+ invitation_link="invite-abc",
+ user_id="user-123",
+ password=password,
+ )
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: claim_onboarding_link reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.master_key", "sk-test"), # test-quality-ok: same as above
+ patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: same as above
+ patch("litellm.proxy.proxy_server.premium_user", False), # test-quality-ok: same as above
+ patch( # test-quality-ok: same as above
+ "litellm.proxy.proxy_server.generate_key_helper_fn",
+ new_callable=AsyncMock,
+ return_value={"token": "sk-generated-key", "user_id": "user-123"},
+ ),
+ patch( # test-quality-ok: same as above
+ "litellm.proxy.proxy_server.get_custom_url",
+ return_value="http://localhost:4000/",
+ ),
+ patch( # test-quality-ok: same as above
+ "litellm.proxy.proxy_server.get_disabled_non_admin_personal_key_creation",
+ return_value=False,
+ ),
+ patch("litellm.proxy.proxy_server.get_server_root_path", return_value=""), # test-quality-ok: same as above
+ ):
+ result = await claim_onboarding_link(data=data, request=request)
+
+ assert "token" in result
+ prisma.db.litellm_usertable.update.assert_called_once()
diff --git a/tests/test_litellm/proxy/auth/test_password_policy.py b/tests/test_litellm/proxy/auth/test_password_policy.py
index f6e7d443907..edc88d21218 100644
--- a/tests/test_litellm/proxy/auth/test_password_policy.py
+++ b/tests/test_litellm/proxy/auth/test_password_policy.py
@@ -2,22 +2,54 @@
Tests for the configurable password-strength policy in
`litellm.proxy.auth.password_policy`, enforced on every path that persists a
new or changed password for a locally-managed user.
+
+The breach-check (HIBP) tests inject a real AsyncHTTPHandler wrapping an
+httpx.MockTransport, so no network is touched and nothing is monkeypatched.
"""
+import hashlib
+
+import httpx
import pytest
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import ProxyErrorTypes, ProxyException
from litellm.proxy.auth.password_policy import (
DEFAULT_MIN_LENGTH,
MIN_ALLOWED_LENGTH,
PasswordPolicy,
get_password_policy,
+ validate_password_not_breached,
validate_password_policy,
)
STRONG_PASSWORD = "Str0ng!Passw0rd"
+def _sha1_upper(password: str) -> str:
+ return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+
+
+def _client_with_transport(handler) -> AsyncHTTPHandler:
+ http_handler = AsyncHTTPHandler()
+ http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ return http_handler
+
+
+def _client_never_called() -> AsyncHTTPHandler:
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise AssertionError(f"unexpected HTTP call to {request.url}")
+
+ return _client_with_transport(handler)
+
+
+def _client_returning(body: str, status_code: int = 200) -> AsyncHTTPHandler:
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(status_code, text=body)
+
+ return _client_with_transport(handler)
+
+
def test_get_password_policy_defaults_to_pif_baseline():
policy = get_password_policy({})
assert policy == PasswordPolicy(
@@ -134,3 +166,105 @@ def test_validate_password_policy_rejects_unicode_letter_as_special_character():
def test_validate_password_policy_accepts_real_special_character_with_unicode_letters():
"""Same base password as the rejection test above, plus an actual symbol."""
assert validate_password_policy("Passwörd1234!", {}) is None
+
+
+@pytest.mark.asyncio
+async def test_breach_check_skipped_when_disabled():
+ result = await validate_password_not_breached(
+ password="password12345", # breached in reality, but the check is off
+ general_settings={"password_policy_check_breached_passwords": False},
+ client=_client_never_called(),
+ )
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_rejects_breached_password():
+ password = "correct horse battery staple"
+ sha1 = _sha1_upper(password)
+ body = f"AAAA000000000000000000000000000000A:0\r\n{sha1[5:]}:42\r\nBBBB000000000000000000000000000000B:7"
+
+ with pytest.raises(ProxyException) as exc_info:
+ await validate_password_not_breached(password=password, general_settings={}, client=_client_returning(body))
+ assert exc_info.value.code == "400"
+ assert exc_info.value.type == ProxyErrorTypes.validation_error
+ assert exc_info.value.param == "password"
+ assert "data breaches" in exc_info.value.message
+
+
+@pytest.mark.asyncio
+async def test_only_sha1_prefix_leaves_the_proxy():
+ password = "a very secret password"
+ sha1 = _sha1_upper(password)
+ captured_requests: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ captured_requests.append(request)
+ return httpx.Response(200, text="0000000000000000000000000000000000A:1")
+
+ result = await validate_password_not_breached(
+ password=password, general_settings={}, client=_client_with_transport(handler)
+ )
+ assert result is None
+
+ (request,) = captured_requests
+ assert request.url.path == f"/range/{sha1[:5]}"
+ assert sha1[5:] not in str(request.url)
+ assert request.headers["Add-Padding"] == "true"
+ assert "litellm" in request.headers["User-Agent"]
+
+
+@pytest.mark.asyncio
+async def test_ignores_padding_entries_with_zero_count():
+ """HIBP padding entries (requested via Add-Padding) carry count 0 and must
+ not be treated as breaches when they collide with the password's suffix."""
+ password = "a padded-away password"
+ sha1 = _sha1_upper(password)
+
+ result = await validate_password_not_breached(
+ password=password, general_settings={}, client=_client_returning(f"{sha1[5:]}:0")
+ )
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_accepts_password_absent_from_breach_corpus():
+ result = await validate_password_not_breached(
+ password="a genuinely novel password",
+ general_settings={},
+ client=_client_returning("0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2"),
+ )
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_breach_check_fails_open_on_network_error():
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectError("no route to host")
+
+ result = await validate_password_not_breached(
+ password="password12345", # breached, but HIBP is unreachable
+ general_settings={},
+ client=_client_with_transport(handler),
+ )
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_breach_check_fails_open_on_http_error_status():
+ result = await validate_password_not_breached(
+ password="password12345",
+ general_settings={},
+ client=_client_returning("service unavailable", status_code=503),
+ )
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_breach_check_fails_open_on_malformed_response_body():
+ result = await validate_password_not_breached(
+ password="password12345",
+ general_settings={},
+ client=_client_returning(f"{_sha1_upper('password12345')[5:]}:not-a-number"),
+ )
+ assert result is None
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index 0d8b19345f1..e7b5172e0fb 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -1,9 +1,12 @@
+import hashlib
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Final
+import httpx
import pytest
+import respx
from fastapi.testclient import TestClient
from fastapi import HTTPException
from pytest_mock import MockerFixture
@@ -4502,6 +4505,11 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo
_update_single_user_helper,
)
+ mocker.patch( # test-quality-ok: same module-global mocking every test in this file already uses
+ "litellm.proxy.proxy_server.general_settings",
+ {"password_policy_check_breached_passwords": False},
+ )
+
mock_prisma_client = _admin_prisma
existing_user = mocker.MagicMock()
existing_user.model_dump.return_value = {"user_id": "target-user"}
@@ -4519,3 +4527,29 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo
written_data = mock_prisma_client.update_data.call_args.kwargs["data"]
assert written_data.get("password") is not None
assert written_data["password"] != strong_password
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_user_update_rejects_breached_password(_admin_prisma):
+ """A strength-passing password found in the HIBP corpus must be rejected
+ before it ever reaches the DB write."""
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ _update_single_user_helper,
+ )
+
+ password = "Str0ng!Passw0rd"
+ sha1 = hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+ respx.get(f"https://api.pwnedpasswords.com/range/{sha1[:5]}").mock(
+ return_value=httpx.Response(200, text=f"{sha1[5:]}:1387")
+ )
+
+ user_request = UpdateUserRequest(user_id="target-user", password=password)
+ admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
+
+ with pytest.raises(ProxyException) as exc_info:
+ await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
+
+ assert exc_info.value.code == "400"
+ assert "data breaches" in exc_info.value.message
+ _admin_prisma.db.litellm_usertable.find_first.assert_not_called()
diff --git a/tests/test_litellm/proxy/test__types.py b/tests/test_litellm/proxy/test__types.py
index 26bb1533da4..8f9c44d7a38 100644
--- a/tests/test_litellm/proxy/test__types.py
+++ b/tests/test_litellm/proxy/test__types.py
@@ -10,6 +10,7 @@ from litellm.proxy._types import (
LiteLLM_AuditLogs,
LiteLLM_TeamMembership,
LitellmUserRoles,
+ NewUserRequest,
OrganizationMemberUpdateRequest,
ResetSpendRequest,
UpdateKeyRequest,
@@ -277,3 +278,18 @@ def test_team_membership_budget_table_present_still_works():
}
result = LiteLLM_TeamMembership.model_validate(data)
assert result.litellm_budget_table is None
+
+
+def test_new_user_request_loudly_rejects_a_password():
+ """
+ /user/new has never persisted a password (the field used to be silently
+ dropped). Sending one must now fail visibly so the dead path cannot be
+ revived without going through the password policy.
+ """
+ with pytest.raises(ValidationError, match="invitation link"):
+ NewUserRequest(user_email="alice@example.com", password="hunter2hunter2")
+
+
+def test_new_user_request_without_password_still_works():
+ request = NewUserRequest(user_email="alice@example.com")
+ assert request.password is None
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 7eadaa6c991..0cc49382498 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -32869,6 +32869,8 @@ export interface components {
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
/** Organizations */
organizations?: string[] | null;
+ /** Password */
+ password?: string | null;
/**
* Permissions
* @default {}
From e2ea7e97a58300c2d885c1f1c04854799fbbfcf6 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Thu, 3 Sep 2026 16:54:26 +0200
Subject: [PATCH 02/16] fix(auth): document /user/new password rejection and
format password_policy
---
litellm/proxy/auth/password_policy.py | 3 +--
litellm/proxy/management_endpoints/internal_user_endpoints.py | 1 +
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py
index 80c3ebeedca..84680fa9020 100644
--- a/litellm/proxy/auth/password_policy.py
+++ b/litellm/proxy/auth/password_policy.py
@@ -151,8 +151,7 @@ async def validate_password_not_breached(
return
raise ProxyException(
message=(
- "This password appears in known data breaches and cannot be used. "
- "Please choose a different password."
+ "This password appears in known data breaches and cannot be used. Please choose a different password."
),
type=ProxyErrorTypes.validation_error,
param="password",
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index f22bad70817..409877bdc6f 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -495,6 +495,7 @@ async def new_user(
- prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
- organizations: List[str] - List of organization id's the user is a member of
- budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
+ - password: Optional[str] - Not supported; any value is rejected with a 422. Users set their own password through an invitation link (POST /invitation/new).
Returns:
- key: (str) The generated api key for the user
- expires: (datetime) Datetime object for when key expires.
From 1f0ab3d176af4955a1c75b1b44371a310529e169 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Thu, 3 Sep 2026 16:57:28 +0200
Subject: [PATCH 03/16] fix(auth): drop general_settings import left unused in
new_user
---
litellm/proxy/management_endpoints/internal_user_endpoints.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 409877bdc6f..ddef2127945 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -515,7 +515,7 @@ async def new_user(
```
"""
try:
- from litellm.proxy.proxy_server import _license_check, general_settings, prisma_client
+ from litellm.proxy.proxy_server import _license_check, prisma_client
if prisma_client is None:
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
From fcf7cb6e0c7fc5d39c7f4017630c48becb086ca9 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Thu, 3 Sep 2026 17:12:27 +0200
Subject: [PATCH 04/16] fix(ui): regenerate schema.d.ts for the new_user
password docstring
---
ui/litellm-dashboard/src/lib/http/schema.d.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 0cc49382498..c5ee810069d 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -16798,6 +16798,7 @@ export interface paths {
* - prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
* - organizations: List[str] - List of organization id's the user is a member of
* - budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
+ * - password: Optional[str] - Not supported; any value is rejected with a 422. Users set their own password through an invitation link (POST /invitation/new).
* Returns:
* - key: (str) The generated api key for the user
* - expires: (datetime) Datetime object for when key expires.
From a9a0bcb9f84909cb41e3670b881292d2e415c1b8 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Fri, 4 Sep 2026 21:13:14 +0200
Subject: [PATCH 05/16] move hibp url to constants
---
litellm/constants.py | 3 +++
litellm/proxy/auth/password_policy.py | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/litellm/constants.py b/litellm/constants.py
index 5751e6e46af..59a7c3d1e40 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -2039,3 +2039,6 @@ BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY: Final = "batch_enqueued_token_limit"
# Shared read-only empty mapping, for defaulting optional Mapping parameters without
# constructing a fresh mutable dict at each call site.
EMPTY_MAPPING: Final = MappingProxyType({})
+
+# API endpoint for breached password k-anonymity search
+HIBP_RANGE_API_BASE: Final = "https://api.pwnedpasswords.com/range"
\ No newline at end of file
diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py
index 84680fa9020..958547d6fc7 100644
--- a/litellm/proxy/auth/password_policy.py
+++ b/litellm/proxy/auth/password_policy.py
@@ -18,11 +18,11 @@ from typing import Final
from litellm._logging import verbose_proxy_logger
from litellm._version import version
+from litellm.constants import HIBP_RANGE_API_BASE
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client
from litellm.proxy._types import ProxyErrorTypes, ProxyException
from litellm.types.llms.custom_http import httpxSpecialProvider
-HIBP_RANGE_API_BASE: Final = "https://api.pwnedpasswords.com/range"
HIBP_TIMEOUT_SECONDS: Final = 5.0
DEFAULT_MIN_LENGTH: Final = 12
From 0bb0218d0b60dddb1a05ad2e5ef498e417e210e9 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Tue, 8 Sep 2026 16:27:06 +0200
Subject: [PATCH 06/16] fix(auth): screen bulk-update passwords concurrently
before any db write
/user/bulk_update awaited a separate HIBP lookup for each user in the
batch, so a degraded-slow HIBP (5s timeout per lookup) could stretch a
500-user batch to ~2500s and time out the request after some updates
had already persisted.
validate_passwords_bulk dedupes the batch's passwords, strength-checks
first, then fires every needed HIBP lookup concurrently, bounding the
worst case at one 5s timeout window. bulk_update_processed_users now
screens the whole batch before the serial update loop, so a rejected
password fails only its own entry and validation failures precede any
persistence.
---
litellm/constants.py | 2 +-
litellm/proxy/auth/password_policy.py | 79 ++-
.../internal_user_endpoints.py | 43 +-
.../proxy/auth/test_password_policy.py | 75 +++
.../test_internal_user_endpoints.py | 575 +++++++-----------
5 files changed, 390 insertions(+), 384 deletions(-)
diff --git a/litellm/constants.py b/litellm/constants.py
index 59a7c3d1e40..5f8fa203b37 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -2041,4 +2041,4 @@ BATCH_ENQUEUED_TOKEN_LIMIT_METADATA_KEY: Final = "batch_enqueued_token_limit"
EMPTY_MAPPING: Final = MappingProxyType({})
# API endpoint for breached password k-anonymity search
-HIBP_RANGE_API_BASE: Final = "https://api.pwnedpasswords.com/range"
\ No newline at end of file
+HIBP_RANGE_API_BASE: Final = "https://api.pwnedpasswords.com/range"
diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py
index 958547d6fc7..c27f276252a 100644
--- a/litellm/proxy/auth/password_policy.py
+++ b/litellm/proxy/auth/password_policy.py
@@ -11,9 +11,11 @@ of the password's SHA-1 hash ever leave the proxy, and the check fails open
(allows the password) when HIBP is unreachable.
"""
+import asyncio
import hashlib
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from dataclasses import dataclass
+from types import MappingProxyType
from typing import Final
from litellm._logging import verbose_proxy_logger
@@ -136,6 +138,33 @@ async def _is_password_breached(password: str, client: AsyncHTTPHandler) -> bool
return breached
+def is_breach_check_enabled(general_settings: Mapping[str, object]) -> bool:
+ return general_settings.get("password_policy_check_breached_passwords", True) is not False
+
+
+async def is_password_breached(
+ password: str,
+ general_settings: Mapping[str, object],
+ client: AsyncHTTPHandler | None = None,
+) -> bool:
+ """False when the check is disabled, the password is absent from the HIBP
+ corpus, or HIBP is unreachable (fail open)."""
+ if not is_breach_check_enabled(general_settings):
+ return False
+ return await _is_password_breached(password, client if client is not None else _hibp_client())
+
+
+def breached_password_error() -> ProxyException:
+ return ProxyException(
+ message=(
+ "This password appears in known data breaches and cannot be used. Please choose a different password."
+ ),
+ type=ProxyErrorTypes.validation_error,
+ param="password",
+ code=400,
+ )
+
+
async def validate_password_not_breached(
password: str,
general_settings: Mapping[str, object],
@@ -144,16 +173,42 @@ async def validate_password_not_breached(
"""Raise ``ProxyException`` (400) if ``password`` appears in a known data breach.
Fails open: an unreachable or misbehaving HIBP allows the password."""
- check_enabled: Final = general_settings.get("password_policy_check_breached_passwords", True) is not False
- if not check_enabled:
+ if not await is_password_breached(password, general_settings, client):
return
- if not await _is_password_breached(password, client if client is not None else _hibp_client()):
- return
- raise ProxyException(
- message=(
- "This password appears in known data breaches and cannot be used. Please choose a different password."
- ),
- type=ProxyErrorTypes.validation_error,
- param="password",
- code=400,
+ raise breached_password_error()
+
+
+def _strength_verdict(password: str, general_settings: Mapping[str, object]) -> ProxyException | None:
+ try:
+ validate_password_policy(password, general_settings)
+ except ProxyException as e:
+ return e
+ return None
+
+
+async def validate_passwords_bulk(
+ passwords: Sequence[str],
+ general_settings: Mapping[str, object],
+ client: AsyncHTTPHandler | None = None,
+) -> Mapping[str, ProxyException | None]:
+ """Per-unique-password policy verdicts for a batch: the ProxyException to
+ surface, or None when the password is acceptable.
+
+ Deduplicates first, then issues every needed HIBP lookup concurrently, so a
+ batch caller pays one HIBP timeout window in the worst case instead of one
+ per password (each lookup still fails open independently)."""
+ unique_passwords: Final = tuple(dict.fromkeys(passwords))
+ strength_verdicts: Final[Mapping[str, ProxyException | None]] = MappingProxyType(
+ {password: _strength_verdict(password, general_settings) for password in unique_passwords}
+ )
+ to_screen: Final = tuple(password for password in unique_passwords if strength_verdicts[password] is None)
+ breached_flags: Final = await asyncio.gather(
+ *(is_password_breached(password, general_settings, client) for password in to_screen)
+ )
+ breached_passwords: Final = frozenset(password for password, breached in zip(to_screen, breached_flags) if breached)
+ return MappingProxyType(
+ {
+ password: breached_password_error() if password in breached_passwords else strength_verdicts[password]
+ for password in unique_passwords
+ }
)
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index ddef2127945..58a8ba0b09d 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -27,9 +27,14 @@ from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import get_team_object, get_user_object
-from litellm.proxy.auth.password_policy import validate_password_not_breached, validate_password_policy
+from litellm.proxy.auth.password_policy import (
+ validate_password_not_breached,
+ validate_password_policy,
+ validate_passwords_bulk,
+)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.user_api_key_cache import (
@@ -162,11 +167,17 @@ def _team_membership_table(
return team_membership_table
-async def _hash_password_in_dict(data: dict, general_settings: Mapping[str, object]) -> None:
- """Validate and hash password field in-place if present."""
+async def _hash_password_in_dict(
+ data: dict, general_settings: Mapping[str, object], password_prevalidated: bool = False
+) -> None:
+ """Validate and hash password field in-place if present.
+
+ ``password_prevalidated`` skips the policy checks for callers that already
+ validated the password (the bulk path screens its whole batch upfront)."""
if "password" in data and data["password"] is not None:
- validate_password_policy(data["password"], general_settings)
- await validate_password_not_breached(data["password"], general_settings)
+ if not password_prevalidated:
+ validate_password_policy(data["password"], general_settings)
+ await validate_password_not_breached(data["password"], general_settings)
data["password"] = hash_password(data["password"])
@@ -1429,6 +1440,7 @@ async def _update_single_user_helper(
user_request: UpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None = None,
+ password_prevalidated: bool = False,
) -> dict[str, Any]:
"""
Helper function to update a single user.
@@ -1451,7 +1463,7 @@ async def _update_single_user_helper(
data_json: Final[dict] = user_request.model_dump(exclude_unset=True)
non_default_values = _update_internal_user_params(data_json=data_json, data=user_request)
- await _hash_password_in_dict(non_default_values, general_settings)
+ await _hash_password_in_dict(non_default_values, general_settings, password_prevalidated=password_prevalidated)
existing_user_row: BaseModel | None = None
if user_request.user_id:
@@ -1700,19 +1712,38 @@ async def bulk_update_processed_users(
users_to_update: list[UpdateUserRequest],
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None = None,
+ hibp_client: AsyncHTTPHandler | None = None,
) -> BulkUpdateUserResponse:
+ from litellm.proxy.proxy_server import general_settings
+
results: Final[list[UserUpdateResult]] = []
successful_updates = 0
failed_updates = 0
+ # Screen the batch's passwords upfront and concurrently: done per-user
+ # inside the loop below, each HIBP lookup would be awaited serially and a
+ # degraded-slow HIBP could stretch a full batch to minutes, timing out the
+ # request after some updates already persisted.
+ password_verdicts: Final = await validate_passwords_bulk(
+ tuple(u.password for u in users_to_update if u.password is not None),
+ general_settings,
+ client=hibp_client,
+ )
+
# Process each user update independently
try:
for user_request in users_to_update:
try:
+ if (
+ user_request.password is not None
+ and (password_error := password_verdicts.get(user_request.password)) is not None
+ ):
+ raise password_error
response = await _update_single_user_helper(
user_request=user_request,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
+ password_prevalidated=True,
)
# Record success
results.append(
diff --git a/tests/test_litellm/proxy/auth/test_password_policy.py b/tests/test_litellm/proxy/auth/test_password_policy.py
index edc88d21218..f9f5025b57f 100644
--- a/tests/test_litellm/proxy/auth/test_password_policy.py
+++ b/tests/test_litellm/proxy/auth/test_password_policy.py
@@ -7,6 +7,7 @@ The breach-check (HIBP) tests inject a real AsyncHTTPHandler wrapping an
httpx.MockTransport, so no network is touched and nothing is monkeypatched.
"""
+import asyncio
import hashlib
import httpx
@@ -21,6 +22,7 @@ from litellm.proxy.auth.password_policy import (
get_password_policy,
validate_password_not_breached,
validate_password_policy,
+ validate_passwords_bulk,
)
STRONG_PASSWORD = "Str0ng!Passw0rd"
@@ -268,3 +270,76 @@ async def test_breach_check_fails_open_on_malformed_response_body():
client=_client_returning(f"{_sha1_upper('password12345')[5:]}:not-a-number"),
)
assert result is None
+
+
+@pytest.mark.asyncio
+async def test_validate_passwords_bulk_screens_concurrently():
+ """All HIBP lookups for a batch must be in flight at once: each handler
+ call stalls until every expected request has arrived, and a handler that
+ gives up waiting reports the password as breached. Serial awaiting (the
+ old per-user behavior) leaves each earlier request waiting forever for the
+ later ones, so every verdict comes back as a breach and the test fails."""
+ passwords = ("Uniqu3!Passw0rd-a", "Uniqu3!Passw0rd-b", "Uniqu3!Passw0rd-c")
+ suffix_by_prefix = {_sha1_upper(p)[:5]: _sha1_upper(p)[5:] for p in passwords}
+ all_arrived = asyncio.Event()
+ arrivals: list[str] = []
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ arrivals.append(request.url.path)
+ if len(arrivals) == len(passwords):
+ all_arrived.set()
+ try:
+ await asyncio.wait_for(all_arrived.wait(), timeout=5)
+ except TimeoutError:
+ return httpx.Response(200, text=f"{suffix_by_prefix[request.url.path.rsplit('/', 1)[-1]]}:1")
+ return httpx.Response(200, text="0000000000000000000000000000000000A:1")
+
+ verdicts = await validate_passwords_bulk(passwords, {}, client=_client_with_transport(handler))
+ assert set(arrivals) == {f"/range/{prefix}" for prefix in suffix_by_prefix}
+ assert all(verdicts[p] is None for p in passwords)
+
+
+@pytest.mark.asyncio
+async def test_validate_passwords_bulk_deduplicates_lookups():
+ """500 users sharing one password must cost exactly one HIBP lookup."""
+ password = "Sh@red-Passw0rd!"
+ request_count = 0
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ nonlocal request_count
+ request_count += 1
+ return httpx.Response(200, text="0000000000000000000000000000000000A:1")
+
+ verdicts = await validate_passwords_bulk((password,) * 500, {}, client=_client_with_transport(handler))
+ assert request_count == 1
+ assert verdicts == {password: None}
+
+
+@pytest.mark.asyncio
+async def test_validate_passwords_bulk_mixed_verdicts():
+ """Weak passwords are rejected without an HIBP lookup; breached ones get
+ the breach error; acceptable ones map to None."""
+ breached = "Br3ached!Passw0rd"
+ clean = "Cl3an!!Passw0rd42"
+ weak = "short1!"
+ breached_sha1 = _sha1_upper(breached)
+ looked_up_prefixes: list[str] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ looked_up_prefixes.append(request.url.path.rsplit("/", 1)[-1])
+ if request.url.path == f"/range/{breached_sha1[:5]}":
+ return httpx.Response(200, text=f"{breached_sha1[5:]}:99")
+ return httpx.Response(200, text="0000000000000000000000000000000000A:1")
+
+ verdicts = await validate_passwords_bulk((breached, clean, weak), {}, client=_client_with_transport(handler))
+ assert _sha1_upper(weak)[:5] not in looked_up_prefixes
+ assert verdicts[clean] is None
+ assert "data breaches" in verdicts[breached].message
+ assert verdicts[breached].code == "400"
+ assert "12 characters" in verdicts[weak].message
+
+
+@pytest.mark.asyncio
+async def test_validate_passwords_bulk_empty_batch_makes_no_lookups():
+ verdicts = await validate_passwords_bulk((), {}, client=_client_never_called())
+ assert verdicts == {}
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index e7b5172e0fb..024d7b8300f 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -11,6 +11,7 @@ from fastapi.testclient import TestClient
from fastapi import HTTPException
from pytest_mock import MockerFixture
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTableFiltered,
@@ -67,9 +68,7 @@ async def test_ui_view_users_with_null_email(mocker, caplog):
# Proxy admin: no org filter, no get_user_object call
response = await ui_view_users(
- user_api_key_dict=UserAPIKeyAuth(
- user_id="test_user", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.PROXY_ADMIN),
user_id="test_user",
user_email=None,
team_id=None,
@@ -77,9 +76,7 @@ async def test_ui_view_users_with_null_email(mocker, caplog):
page_size=50,
)
- assert response == [
- LiteLLM_UserTableFiltered(user_id="test-user-null-email", user_email=None)
- ]
+ assert response == [LiteLLM_UserTableFiltered(user_id="test-user-null-email", user_email=None)]
@pytest.mark.asyncio
@@ -103,9 +100,7 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker):
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
await ui_view_users(
- user_api_key_dict=UserAPIKeyAuth(
- user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
user_id=None,
user_email="foo",
team_id=None,
@@ -128,9 +123,7 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker):
async def mock_find_many(*args, **kwargs):
where = kwargs.get("where") or {}
assert "organization_memberships" in where
- assert where["organization_memberships"] == {
- "some": {"organization_id": {"in": [org_id]}}
- }
+ assert where["organization_memberships"] == {"some": {"organization_id": {"in": [org_id]}}}
return []
mock_prisma_client.db.litellm_usertable.find_many = mock_find_many
@@ -268,9 +261,7 @@ async def test_ui_view_users_flag_on_team_admin_org_team(mocker):
async def mock_find_many(*args, **kwargs):
where = kwargs.get("where") or {}
assert "organization_memberships" in where
- assert where["organization_memberships"] == {
- "some": {"organization_id": {"in": [org_id]}}
- }
+ assert where["organization_memberships"] == {"some": {"organization_id": {"in": [org_id]}}}
return []
mock_prisma_client.db.litellm_usertable.find_many = mock_find_many
@@ -401,9 +392,7 @@ async def test_ui_view_users_flag_on_team_admin_org_member_no_team_id(mocker):
async def mock_find_many(*args, **kwargs):
where = kwargs.get("where") or {}
assert "organization_memberships" in where
- assert where["organization_memberships"] == {
- "some": {"organization_id": {"in": [org_id]}}
- }
+ assert where["organization_memberships"] == {"some": {"organization_id": {"in": [org_id]}}}
return []
mock_prisma_client.db.litellm_usertable.find_many = mock_find_many
@@ -462,9 +451,7 @@ async def test_ui_view_users_flag_on_team_admin_not_in_org_resolves_via_key_team
async def mock_find_many(*args, **kwargs):
where = kwargs.get("where") or {}
assert "organization_memberships" in where
- assert where["organization_memberships"] == {
- "some": {"organization_id": {"in": [org_id]}}
- }
+ assert where["organization_memberships"] == {"some": {"organization_id": {"in": [org_id]}}}
return []
mock_prisma_client.db.litellm_usertable.find_many = mock_find_many
@@ -507,9 +494,7 @@ async def test_ui_view_users_flag_on_team_admin_not_in_org_resolves_via_key_team
# No team_id query param, but team_id on the API key
response = await ui_view_users(
- user_api_key_dict=UserAPIKeyAuth(
- user_id="team-admin-no-org", user_role=None, team_id=tid
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="team-admin-no-org", user_role=None, team_id=tid),
user_id=None,
user_email="u",
team_id=None,
@@ -538,13 +523,9 @@ def test_user_daily_activity_types():
# Assert all fields in SpendMetrics are reported in DailySpendMetadata as "total_"
for field in spend_metrics.__dict__:
if field.startswith("total_"):
- assert hasattr(
- daily_spend_metadata, field
- ), f"Field {field} is not reported in DailySpendMetadata"
+ assert hasattr(daily_spend_metadata, field), f"Field {field} is not reported in DailySpendMetadata"
else:
- assert not hasattr(
- daily_spend_metadata, field
- ), f"Field {field} is reported in DailySpendMetadata"
+ assert not hasattr(daily_spend_metadata, field), f"Field {field} is reported in DailySpendMetadata"
@pytest.mark.asyncio
@@ -591,9 +572,7 @@ async def test_get_users_includes_timestamps(mocker):
# Call get_users function directly with proxy admin auth
admin_key = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
- response = await get_users(
- page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None
- )
+ response = await get_users(page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None)
print("user /list response: ", response)
@@ -654,14 +633,10 @@ async def test_get_users_redacts_scim_enterprise_metadata(mocker):
)
admin_key = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
- response = await get_users(
- page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None
- )
+ response = await get_users(page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None)
listed = response["users"][0]
- assert listed.metadata == {
- "scim_metadata": {"givenName": "Jane", "familyName": "Doe"}
- }
+ assert listed.metadata == {"scim_metadata": {"givenName": "Jane", "familyName": "Doe"}}
assert "scim_enterprise" not in (listed.metadata or {})
@@ -853,9 +828,7 @@ async def test_new_user_license_over_limit(mocker):
mocker.patch("litellm.proxy.proxy_server._license_check", mock_license_check)
# Create test request data
- user_request = NewUserRequest(
- user_email="test@example.com", user_role="internal_user"
- )
+ user_request = NewUserRequest(user_email="test@example.com", user_role="internal_user")
# Mock user_api_key_dict
mock_user_api_key_dict = UserAPIKeyAuth(user_id="test_admin")
@@ -916,9 +889,7 @@ async def test_new_user_license_gate_counts_only_billable_users(mocker):
request = NewUserRequest(user_role="internal_user")
# 2 active + 3 deactivated -> billable 2, not over max_users 2: gate passes
- mocker.patch(
- "litellm.proxy.proxy_server.prisma_client", _prisma(total=5, deactivated=3)
- )
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", _prisma(total=5, deactivated=3))
with pytest.raises(ProxyException) as passed:
await new_user(data=request, user_api_key_dict=admin)
assert key_gen.call_count == 1
@@ -926,9 +897,7 @@ async def test_new_user_license_gate_counts_only_billable_users(mocker):
# 3 active, 0 deactivated -> billable 3, over max_users 2: gate blocks
key_gen.reset_mock()
- mocker.patch(
- "litellm.proxy.proxy_server.prisma_client", _prisma(total=3, deactivated=0)
- )
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", _prisma(total=3, deactivated=0))
with pytest.raises(ProxyException) as blocked:
await new_user(data=request, user_api_key_dict=admin)
assert blocked.value.code == 403 or blocked.value.code == "403"
@@ -978,14 +947,10 @@ async def test_new_user_non_admin_cannot_create_admin(mocker):
mocker.patch("litellm.proxy.proxy_server._license_check", mock_license_check)
# Test Case 1: INTERNAL_USER trying to create PROXY_ADMIN
- user_request = NewUserRequest(
- user_email="admin@example.com", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ user_request = NewUserRequest(user_email="admin@example.com", user_role=LitellmUserRoles.PROXY_ADMIN)
# Mock user_api_key_dict with non-admin role
- mock_user_api_key_dict = UserAPIKeyAuth(
- user_id="test_internal_user", user_role=LitellmUserRoles.INTERNAL_USER
- )
+ mock_user_api_key_dict = UserAPIKeyAuth(user_id="test_internal_user", user_role=LitellmUserRoles.INTERNAL_USER)
# Call new_user function and expect ProxyException
with pytest.raises(ProxyException) as exc_info:
@@ -993,9 +958,7 @@ async def test_new_user_non_admin_cannot_create_admin(mocker):
# Verify the exception details
assert exc_info.value.code == 403 or exc_info.value.code == "403"
- assert "Only proxy admins can create administrative users" in str(
- exc_info.value.message
- )
+ assert "Only proxy admins can create administrative users" in str(exc_info.value.message)
assert "proxy_admin" in str(exc_info.value.message)
assert "proxy_admin_viewer" in str(exc_info.value.message)
assert str(LitellmUserRoles.PROXY_ADMIN) in str(exc_info.value.message)
@@ -1008,15 +971,11 @@ async def test_new_user_non_admin_cannot_create_admin(mocker):
)
with pytest.raises(ProxyException) as exc_info2:
- await new_user(
- data=user_request_viewer, user_api_key_dict=mock_user_api_key_dict
- )
+ await new_user(data=user_request_viewer, user_api_key_dict=mock_user_api_key_dict)
# Verify the exception details
assert exc_info2.value.code == 403 or exc_info2.value.code == "403"
- assert "Only proxy admins can create administrative users" in str(
- exc_info2.value.message
- )
+ assert "Only proxy admins can create administrative users" in str(exc_info2.value.message)
assert str(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) in str(exc_info2.value.message)
@@ -1055,9 +1014,7 @@ async def test_new_user_non_admin_permissions_non_empty_rejected(mocker):
user_role=LitellmUserRoles.INTERNAL_USER,
permissions={"get_spend_routes": True},
)
- caller = UserAPIKeyAuth(
- user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN
- )
+ caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN)
with pytest.raises(ProxyException) as exc_info:
await new_user(data=data, user_api_key_dict=caller)
@@ -1101,9 +1058,7 @@ async def test_new_user_non_admin_permissions_explicit_empty_rejected(mocker):
permissions={},
)
assert "permissions" in data.model_fields_set
- caller = UserAPIKeyAuth(
- user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN
- )
+ caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN)
with pytest.raises(ProxyException) as exc_info:
await new_user(data=data, user_api_key_dict=caller)
@@ -1156,9 +1111,7 @@ async def test_new_user_non_admin_omits_permissions_succeeds(mocker):
user_role=LitellmUserRoles.INTERNAL_USER,
)
assert "permissions" not in data.model_fields_set
- caller = UserAPIKeyAuth(
- user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN
- )
+ caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN)
result = await new_user(data=data, user_api_key_dict=caller)
assert result is not None
@@ -1232,14 +1185,10 @@ async def test_update_single_user_non_admin_permissions_rejected(mocker):
user_id="alice",
permissions={"get_spend_routes": True},
)
- caller = UserAPIKeyAuth(
- user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN
- )
+ caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN)
with pytest.raises(HTTPException) as exc_info:
- await _update_single_user_helper(
- user_request=data, user_api_key_dict=caller
- )
+ await _update_single_user_helper(user_request=data, user_api_key_dict=caller)
assert exc_info.value.status_code == 403
assert "permissions" in str(exc_info.value.detail)
@@ -1261,14 +1210,10 @@ async def test_update_single_user_non_admin_permissions_explicit_empty_rejected(
data = UpdateUserRequest(user_id="alice", permissions={})
assert "permissions" in data.model_fields_set
- caller = UserAPIKeyAuth(
- user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN
- )
+ caller = UserAPIKeyAuth(user_id="org-admin", user_role=LitellmUserRoles.ORG_ADMIN)
with pytest.raises(HTTPException) as exc_info:
- await _update_single_user_helper(
- user_request=data, user_api_key_dict=caller
- )
+ await _update_single_user_helper(user_request=data, user_api_key_dict=caller)
assert exc_info.value.status_code == 403
assert "permissions" in str(exc_info.value.detail)
@@ -1324,15 +1269,11 @@ async def test_user_info_url_encoding_plus_character(mocker):
mock_request.url.query = "user_id=machine-user+alp-air-admin-b58-b@tempus.com"
# Mock user_api_key_dict
- mock_user_api_key_dict = UserAPIKeyAuth(
- user_id="test_admin", user_role="proxy_admin"
- )
+ mock_user_api_key_dict = UserAPIKeyAuth(user_id="test_admin", user_role="proxy_admin")
# Call user_info function with the URL-decoded user_id (as FastAPI would pass it)
# FastAPI would normally convert + to space, but our fix should handle this
- decoded_user_id = (
- "machine-user alp-air-admin-b58-b@tempus.com" # What FastAPI gives us
- )
+ decoded_user_id = "machine-user alp-air-admin-b58-b@tempus.com" # What FastAPI gives us
expected_user_id = "machine-user+alp-air-admin-b58-b@tempus.com"
response = await user_info(
@@ -1383,9 +1324,7 @@ async def test_user_info_nonexistent_user(mocker):
mock_request = mocker.MagicMock(spec=Request)
# Mock user_api_key_dict
- mock_user_api_key_dict = UserAPIKeyAuth(
- user_id="test_admin", user_role="proxy_admin"
- )
+ mock_user_api_key_dict = UserAPIKeyAuth(user_id="test_admin", user_role="proxy_admin")
# Call user_info function with a non-existent user_id
nonexistent_user_id = "nonexistent-user@example.com"
@@ -1423,14 +1362,10 @@ async def test_user_info_no_user_id_view_only_admin_gets_proxy_admin_payload(moc
mock_get_user_info_for_proxy_admin,
)
- viewer = UserAPIKeyAuth(
- user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value
- )
+ viewer = UserAPIKeyAuth(user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value)
mock_request = mocker.MagicMock(spec=Request)
- response = await user_info(
- user_id=None, user_api_key_dict=viewer, request=mock_request
- )
+ response = await user_info(user_id=None, user_api_key_dict=viewer, request=mock_request)
mock_get_user_info_for_proxy_admin.assert_awaited_once_with(user_api_key_dict=viewer)
assert response is admin_payload
@@ -1457,9 +1392,7 @@ async def test_new_user_default_teams_flow(mocker):
mock_prisma_client.db.litellm_usertable.count = mock_count
persisted_user_row = mocker.MagicMock()
persisted_user_row.teams = ["96fed65b-0182-4ff4-8429-2721cd7d42af"]
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- return_value=persisted_user_row
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(return_value=persisted_user_row)
# Mock duplicate checks to pass
async def mock_check_duplicate_user_email(*args, **kwargs):
@@ -1527,26 +1460,20 @@ async def test_new_user_default_teams_flow(mocker):
)
# Create test request data WITHOUT teams (teams should come from defaults)
- user_request = NewUserRequest(
- user_email="test@example.com", user_role="internal_user"
- )
+ user_request = NewUserRequest(user_email="test@example.com", user_role="internal_user")
# Mock user_api_key_dict
mock_user_api_key_dict = UserAPIKeyAuth(user_id="test_admin")
# Call new_user function
- response = await new_user(
- data=user_request, user_api_key_dict=mock_user_api_key_dict
- )
+ response = await new_user(data=user_request, user_api_key_dict=mock_user_api_key_dict)
# Verify generate_key_helper_fn was called WITHOUT teams
mock_generate_key_helper_fn.assert_called_once()
call_kwargs = mock_generate_key_helper_fn.call_args.kwargs
# Teams should be removed from the data passed to generate_key_helper_fn
- assert (
- "teams" not in call_kwargs
- ), "Teams should not be passed to generate_key_helper_fn"
+ assert "teams" not in call_kwargs, "Teams should not be passed to generate_key_helper_fn"
assert call_kwargs["request_type"] == "user"
assert call_kwargs["user_email"] == "test@example.com"
assert call_kwargs["user_role"] == "internal_user"
@@ -1591,24 +1518,16 @@ def test_update_internal_new_user_params_proxy_admin_role():
try:
# Create test data with PROXY_ADMIN role
- data = NewUserRequest(
- user_email="admin@example.com", user_role=LitellmUserRoles.PROXY_ADMIN.value
- )
+ data = NewUserRequest(user_email="admin@example.com", user_role=LitellmUserRoles.PROXY_ADMIN.value)
data_json = data.model_dump(exclude_unset=True)
# Call the function
result = _update_internal_new_user_params(data_json=data_json, data=data)
# Assertions - default params should NOT be applied for PROXY_ADMIN
- assert (
- "max_budget" not in result
- ), "Default max_budget should NOT be applied to PROXY_ADMIN"
- assert (
- "models" not in result
- ), "Default models should NOT be applied to PROXY_ADMIN"
- assert (
- "tpm_limit" not in result
- ), "Default tpm_limit should NOT be applied to PROXY_ADMIN"
+ assert "max_budget" not in result, "Default max_budget should NOT be applied to PROXY_ADMIN"
+ assert "models" not in result, "Default models should NOT be applied to PROXY_ADMIN"
+ assert "tpm_limit" not in result, "Default tpm_limit should NOT be applied to PROXY_ADMIN"
# These should still work
assert result["user_email"] == "admin@example.com"
@@ -1722,15 +1641,9 @@ async def test_check_duplicate_user_email_case_insensitive(mocker):
user_email_clause = where_clause.get("user_email", {})
# Check that the query structure is correct for case insensitive search
- assert (
- "equals" in user_email_clause
- ), "Query should use 'equals' for case insensitive search"
- assert (
- user_email_clause.get("mode") == "insensitive"
- ), "Query should use 'insensitive' mode"
- assert (
- user_email_clause.get("equals") == "user@example.com"
- ), "Query should search for the provided email"
+ assert "equals" in user_email_clause, "Query should use 'equals' for case insensitive search"
+ assert user_email_clause.get("mode") == "insensitive", "Query should use 'insensitive' mode"
+ assert user_email_clause.get("equals") == "user@example.com", "Query should search for the provided email"
return mock_existing_user # Return existing user to simulate duplicate
@@ -1741,9 +1654,7 @@ async def test_check_duplicate_user_email_case_insensitive(mocker):
await _check_duplicate_user_email("user@example.com", mock_prisma_client)
assert exc_info.value.status_code == 409
- assert "User with email User@Example.com already exists" in str(
- exc_info.value.detail
- )
+ assert "User with email User@Example.com already exists" in str(exc_info.value.detail)
# Test Case 2: No duplicate found
async def mock_find_first_no_duplicate(*args, **kwargs):
@@ -1768,9 +1679,7 @@ async def test_check_duplicate_user_email_case_insensitive(mocker):
pytest.fail(f"Should not raise exception when no duplicate found, but got: {e}")
# Test Case 3: None email should not cause issues
- await _check_duplicate_user_email(
- None, mock_prisma_client
- ) # Should not raise exception
+ await _check_duplicate_user_email(None, mock_prisma_client) # Should not raise exception
@pytest.mark.asyncio
@@ -1880,9 +1789,7 @@ def test_process_keys_for_user_info_filters_dashboard_keys(monkeypatch):
# Verify dashboard key is not in results
result_team_ids = [key.get("team_id") for key in result]
- assert (
- UI_SESSION_TOKEN_TEAM_ID not in result_team_ids
- ), "Dashboard key should be filtered out"
+ assert UI_SESSION_TOKEN_TEAM_ID not in result_team_ids, "Dashboard key should be filtered out"
# Verify regular keys are included
assert "regular-team" in result_team_ids, "Regular team key should be included"
@@ -1892,9 +1799,7 @@ def test_process_keys_for_user_info_filters_dashboard_keys(monkeypatch):
result_tokens = [key.get("token") for key in result]
assert "sk-regular-token" in result_tokens, "Regular key should be included"
assert "sk-no-team-token" in result_tokens, "No-team key should be included"
- assert (
- "sk-dashboard-token" not in result_tokens
- ), "Dashboard key should not be included"
+ assert "sk-dashboard-token" not in result_tokens, "Dashboard key should not be included"
def test_process_keys_for_user_info_handles_none_keys(monkeypatch):
@@ -2331,9 +2236,7 @@ async def test_get_user_daily_activity_non_admin_cannot_view_other_users(monkeyp
)
assert exc_info.value.status_code == 403
- assert "Non-admin users can only view their own spend data" in str(
- exc_info.value.detail
- )
+ assert "Non-admin users can only view their own spend data" in str(exc_info.value.detail)
# Case 2: Non-admin omits user_id — should default to their own user_id
mock_response = MagicMock()
@@ -2620,39 +2523,23 @@ async def test_delete_user_cleans_up_created_by_invitation_links(mocker):
async def mock_find_unique(*args, **kwargs):
return mock_user_row
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
# Mock find_many for teams (no teams)
- mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(
- return_value=[]
- )
+ mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(return_value=[])
# Mock all delete_many calls
- mock_prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock(
- return_value=0
- )
- mock_prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock(
- return_value=1
- )
- mock_prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock(
- return_value=0
- )
- mock_prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(
- return_value=0
- )
- mock_prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(
- return_value=1
- )
+ mock_prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock(return_value=0)
+ mock_prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock(return_value=1)
+ mock_prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock(return_value=0)
+ mock_prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(return_value=0)
+ mock_prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(return_value=1)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Call delete_user
data = DeleteUserRequest(user_ids=["admin-creator"])
- user_api_key_dict = UserAPIKeyAuth(
- user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ user_api_key_dict = UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN)
await delete_user(data=data, user_api_key_dict=user_api_key_dict)
@@ -2661,9 +2548,7 @@ async def test_delete_user_cleans_up_created_by_invitation_links(mocker):
call_kwargs = mock_prisma_client.db.litellm_invitationlink.delete_many.call_args
where_clause = call_kwargs.kwargs.get("where") or call_kwargs[1].get("where")
- assert (
- "OR" in where_clause
- ), "Should use OR to match user_id, created_by, and updated_by"
+ assert "OR" in where_clause, "Should use OR to match user_id, created_by, and updated_by"
or_conditions = where_clause["OR"]
assert len(or_conditions) == 3, "Should have 3 OR conditions"
@@ -2706,9 +2591,7 @@ async def test_delete_user_rejects_org_admin_deleting_outside_scope(mocker):
async def mock_find_unique(*args, **kwargs):
return mock_target_user
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
# Caller (org_admin_user) administers org-A.
caller_membership = mocker.MagicMock()
@@ -2734,16 +2617,12 @@ async def test_delete_user_rejects_org_admin_deleting_outside_scope(mocker):
return [caller_membership]
return []
- mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock(
- side_effect=mock_find_memberships
- )
+ mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock(side_effect=mock_find_memberships)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
data = DeleteUserRequest(user_ids=["victim"])
- user_api_key_dict = UserAPIKeyAuth(
- user_id="org_admin_user", user_role=LitellmUserRoles.ORG_ADMIN
- )
+ user_api_key_dict = UserAPIKeyAuth(user_id="org_admin_user", user_role=LitellmUserRoles.ORG_ADMIN)
with pytest.raises(HTTPException) as exc:
await delete_user(data=data, user_api_key_dict=user_api_key_dict)
@@ -2751,11 +2630,8 @@ async def test_delete_user_rejects_org_admin_deleting_outside_scope(mocker):
# Critical: no delete_many calls should have executed.
assert (
- not hasattr(
- mock_prisma_client.db.litellm_verificationtoken.delete_many, "mock_calls"
- )
- or len(mock_prisma_client.db.litellm_verificationtoken.delete_many.mock_calls)
- == 0
+ not hasattr(mock_prisma_client.db.litellm_verificationtoken.delete_many, "mock_calls")
+ or len(mock_prisma_client.db.litellm_verificationtoken.delete_many.mock_calls) == 0
)
@@ -2774,9 +2650,7 @@ async def test_user_update_rejects_silent_create_for_non_proxy_admin(mocker):
mock_prisma_client = mocker.MagicMock()
# user_email lookup yields None → would silently create pre-fix.
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=None
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=None)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
user_request = UpdateUserRequest(
@@ -2790,9 +2664,7 @@ async def test_user_update_rejects_silent_create_for_non_proxy_admin(mocker):
)
with pytest.raises(HTTPException) as exc:
- await _update_single_user_helper(
- user_request=user_request, user_api_key_dict=org_admin
- )
+ await _update_single_user_helper(user_request=user_request, user_api_key_dict=org_admin)
assert exc.value.status_code == 404
@@ -2836,17 +2708,13 @@ async def test_user_info_v2_proxy_admin_can_query_any_user(mocker):
return mock_user_row
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- admin_key = UserAPIKeyAuth(
- user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ admin_key = UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN)
response = await user_info_v2(
request=mock_request,
@@ -2900,17 +2768,13 @@ async def test_user_info_v2_redacts_scim_enterprise_metadata(mocker):
return mock_user_row
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- admin_key = UserAPIKeyAuth(
- user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ admin_key = UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN)
response = await user_info_v2(
request=mock_request,
@@ -2919,9 +2783,7 @@ async def test_user_info_v2_redacts_scim_enterprise_metadata(mocker):
)
assert isinstance(response, UserInfoV2Response)
- assert response.metadata == {
- "scim_metadata": {"givenName": "Jane", "familyName": "Doe"}
- }
+ assert response.metadata == {"scim_metadata": {"givenName": "Jane", "familyName": "Doe"}}
assert "scim_enterprise" not in (response.metadata or {})
@@ -2990,17 +2852,13 @@ async def test_user_info_v2_internal_user_can_query_self(mocker):
return mock_user_row
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- user_key = UserAPIKeyAuth(
- user_id="self-user", user_role=LitellmUserRoles.INTERNAL_USER
- )
+ user_key = UserAPIKeyAuth(user_id="self-user", user_role=LitellmUserRoles.INTERNAL_USER)
response = await user_info_v2(
request=mock_request,
@@ -3035,17 +2893,13 @@ async def test_user_info_v2_internal_user_cannot_query_other(mocker):
return mock_caller_row
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- user_key = UserAPIKeyAuth(
- user_id="caller-user", user_role=LitellmUserRoles.INTERNAL_USER
- )
+ user_key = UserAPIKeyAuth(user_id="caller-user", user_role=LitellmUserRoles.INTERNAL_USER)
with pytest.raises(ProxyException) as exc_info:
await user_info_v2(
@@ -3092,17 +2946,13 @@ async def test_user_info_v2_no_user_id_defaults_to_self(mocker):
return mock_user_row
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- user_key = UserAPIKeyAuth(
- user_id="my-user-id", user_role=LitellmUserRoles.INTERNAL_USER
- )
+ user_key = UserAPIKeyAuth(user_id="my-user-id", user_role=LitellmUserRoles.INTERNAL_USER)
# Call without user_id
response = await user_info_v2(
@@ -3130,17 +2980,13 @@ async def test_user_info_v2_nonexistent_user_returns_404(mocker):
async def mock_find_unique(*args, **kwargs):
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- admin_key = UserAPIKeyAuth(
- user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ admin_key = UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(ProxyException) as exc_info:
await user_info_v2(
@@ -3188,17 +3034,13 @@ async def test_user_info_v2_response_shape(mocker):
async def mock_find_unique(*args, **kwargs):
return mock_user_row
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- admin_key = UserAPIKeyAuth(
- user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ admin_key = UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN)
response = await user_info_v2(
request=mock_request,
@@ -3233,9 +3075,7 @@ async def test_user_info_v2_response_shape(mocker):
# The dashboard's user edit form hydrates its per-model budget rows from
# these two, so dropping them makes a save replace the user's budgets.
- assert response_dict["model_max_budget"] == {
- "gpt-3.5-turbo": {"budget_limit": 5.0, "time_period": "30d"}
- }
+ assert response_dict["model_max_budget"] == {"gpt-3.5-turbo": {"budget_limit": 5.0, "time_period": "30d"}}
assert response_dict["model_max_budget_usage"] == {
"gpt-3.5-turbo": {"current_spend": 0.0, "budget_limit": 5.0, "time_period": "30d"}
}
@@ -3294,9 +3134,7 @@ async def test_user_info_v2_team_admin_can_query_team_member(mocker):
return mock_target
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
# Mock team with caller as admin
mock_team = mocker.MagicMock()
@@ -3313,17 +3151,13 @@ async def test_user_info_v2_team_admin_can_query_team_member(mocker):
async def mock_find_many_teams(*args, **kwargs):
return [mock_team]
- mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(
- side_effect=mock_find_many_teams
- )
+ mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(side_effect=mock_find_many_teams)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- team_admin_key = UserAPIKeyAuth(
- user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER
- )
+ team_admin_key = UserAPIKeyAuth(user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER)
response = await user_info_v2(
request=mock_request,
@@ -3363,9 +3197,7 @@ async def test_user_info_v2_team_admin_cannot_query_non_team_member(mocker):
return mock_target
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
# Mock team where caller is admin
mock_team = mocker.MagicMock()
@@ -3381,17 +3213,13 @@ async def test_user_info_v2_team_admin_cannot_query_non_team_member(mocker):
async def mock_find_many_teams(*args, **kwargs):
return [mock_team]
- mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(
- side_effect=mock_find_many_teams
- )
+ mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(side_effect=mock_find_many_teams)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
- team_admin_key = UserAPIKeyAuth(
- user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER
- )
+ team_admin_key = UserAPIKeyAuth(user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER)
with pytest.raises(ProxyException) as exc_info:
await user_info_v2(
@@ -3441,18 +3269,14 @@ async def test_user_info_v2_url_encoding_plus_character(mocker):
return mock_user_row
return None
- mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(
- side_effect=mock_find_unique
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(side_effect=mock_find_unique)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_request = mocker.MagicMock(spec=Request)
mock_request.url.query = f"user_id={expected_user_id}"
- admin_key = UserAPIKeyAuth(
- user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ admin_key = UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN)
# Simulate FastAPI converting + to space
decoded_user_id = "machine-user admin@example.com"
@@ -3549,9 +3373,7 @@ def test_enforce_user_info_access_admin_bypass():
_enforce_user_info_access,
)
- admin = UserAPIKeyAuth(
- user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN.value
- )
+ admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN.value)
# Should not raise even when querying a different user
_enforce_user_info_access(user_id="someone_else", user_api_key_dict=admin)
@@ -3590,9 +3412,7 @@ def test_enforce_user_info_access_owner_allowed():
_enforce_user_info_access,
)
- user = UserAPIKeyAuth(
- user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value
- )
+ user = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value)
_enforce_user_info_access(user_id="alice", user_api_key_dict=user)
@@ -3604,9 +3424,7 @@ def test_enforce_user_info_access_no_user_id_allowed():
_enforce_user_info_access,
)
- user = UserAPIKeyAuth(
- user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value
- )
+ user = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value)
_enforce_user_info_access(user_id=None, user_api_key_dict=user)
@@ -3663,9 +3481,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_max_budget(mocker, budge
"max_budget": 100,
}
existing_user.user_id = "user-1"
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=existing_user
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
user_request = UpdateUserRequest.model_validate({"user_id": "user-1", budget_field: budget_value})
@@ -3675,9 +3491,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_max_budget(mocker, budge
)
with pytest.raises(HTTPException) as exc:
- await _update_single_user_helper(
- user_request=user_request, user_api_key_dict=caller
- )
+ await _update_single_user_helper(user_request=user_request, user_api_key_dict=caller)
assert exc.value.status_code == 403
assert budget_field in str(exc.value.detail)
mock_prisma_client.update_data.assert_not_called()
@@ -3699,9 +3513,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_spend(mocker):
"spend": 50.0,
}
existing_user.user_id = "user-1"
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=existing_user
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
user_request = UpdateUserRequest(
@@ -3714,9 +3526,7 @@ async def test_ghsa_wvg4_non_admin_cannot_self_escalate_spend(mocker):
)
with pytest.raises(HTTPException) as exc:
- await _update_single_user_helper(
- user_request=user_request, user_api_key_dict=caller
- )
+ await _update_single_user_helper(user_request=user_request, user_api_key_dict=caller)
assert exc.value.status_code == 403
assert "spend" in str(exc.value.detail)
@@ -3735,12 +3545,8 @@ async def test_ghsa_wvg4_proxy_admin_can_update_user_budget(mocker):
"max_budget": 100,
}
existing_user.user_id = "target-user"
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=existing_user
- )
- mock_prisma_client.update_data = mocker.AsyncMock(
- return_value={"user_id": "target-user", "max_budget": 500}
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
+ mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "target-user", "max_budget": 500})
mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
@@ -3754,9 +3560,7 @@ async def test_ghsa_wvg4_proxy_admin_can_update_user_budget(mocker):
user_role=LitellmUserRoles.PROXY_ADMIN,
)
- result = await _update_single_user_helper(
- user_request=user_request, user_api_key_dict=admin_caller
- )
+ result = await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
assert result is not None
@@ -3772,12 +3576,8 @@ async def test_admin_user_update_spend_invalidates_counter(mocker):
existing_user = mocker.MagicMock()
existing_user.model_dump.return_value = {"user_id": "target-user", "spend": 50.0}
existing_user.user_id = "target-user"
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=existing_user
- )
- mock_prisma_client.update_data = mocker.AsyncMock(
- return_value={"user_id": "target-user", "spend": -25.0}
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
+ mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "target-user", "spend": -25.0})
mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
@@ -3792,13 +3592,9 @@ async def test_admin_user_update_spend_invalidates_counter(mocker):
# without raising the recurring budget ceiling. Future changes should
# continue allowing negative spend counters.
user_request = UpdateUserRequest(user_id="target-user", spend=-25)
- admin_caller = UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
- await _update_single_user_helper(
- user_request=user_request, user_api_key_dict=admin_caller
- )
+ await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
mock_invalidate.assert_awaited_once_with(counter_key="spend:user:target-user")
@@ -3815,9 +3611,7 @@ async def test_user_update_rejects_non_finite_spend(mocker):
existing_user = mocker.MagicMock()
existing_user.model_dump.return_value = {"user_id": "target-user", "spend": 50.0}
existing_user.user_id = "target-user"
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=existing_user
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
mock_prisma_client.update_data = mocker.AsyncMock()
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
@@ -3827,14 +3621,10 @@ async def test_user_update_rejects_non_finite_spend(mocker):
)
user_request = UpdateUserRequest(user_id="target-user", spend=float("nan"))
- admin_caller = UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- )
+ admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as exc:
- await _update_single_user_helper(
- user_request=user_request, user_api_key_dict=admin_caller
- )
+ await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
assert exc.value.status_code == 400
mock_prisma_client.update_data.assert_not_called()
mock_invalidate.assert_not_awaited()
@@ -3854,9 +3644,7 @@ async def test_resolve_user_email_metadata_maps_page_user_ids_to_email(mocker):
mock_prisma_client = mocker.MagicMock()
find_many = mocker.AsyncMock(
return_value=[
- SimpleNamespace(
- user_id="u1", user_email="alice@example.com", user_alias="Alice"
- ),
+ SimpleNamespace(user_id="u1", user_email="alice@example.com", user_alias="Alice"),
SimpleNamespace(user_id="u2", user_email=None, user_alias="bob-alias"),
]
)
@@ -4055,19 +3843,13 @@ def _object_permission_mocks(mocker, existing_object_permission_id=None):
}
existing_user.user_id = "target-user"
existing_user.object_permission_id = existing_object_permission_id
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=existing_user
- )
- mock_prisma_client.db.litellm_objectpermissiontable.find_unique = mocker.AsyncMock(
- return_value=None
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_objectpermissiontable.find_unique = mocker.AsyncMock(return_value=None)
mock_prisma_client.db.litellm_objectpermissiontable.upsert = mocker.AsyncMock(
return_value=SimpleNamespace(object_permission_id="perm-new")
)
mock_prisma_client.db.litellm_mcpservertable.find_many = mocker.AsyncMock(return_value=[])
- mock_prisma_client.update_data = mocker.AsyncMock(
- return_value={"user_id": "target-user"}
- )
+ mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "target-user"})
mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
@@ -4103,9 +3885,7 @@ async def test_user_update_persists_mcp_entitlement_and_links_it(mocker):
"mcp_tool_permissions": {"github": ["list_issues"]},
},
),
- user_api_key_dict=UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
upsert_kwargs = mock_prisma_client.db.litellm_objectpermissiontable.upsert.call_args.kwargs
@@ -4139,9 +3919,7 @@ async def test_user_update_invalidates_the_cached_entitlement(mocker):
user_id="target-user",
object_permission={"mcp_tool_permissions": {"github": []}},
),
- user_api_key_dict=UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
deleted = {call.kwargs["key"] for call in cache.async_delete_cache.call_args_list}
@@ -4174,9 +3952,7 @@ async def test_admin_can_clear_a_users_mcp_entitlement(mocker):
await _update_single_user_helper(
user_request=UpdateUserRequest(user_id="target-user", object_permission={}),
- user_api_key_dict=UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
written = mock_prisma_client.update_data.call_args.kwargs["data"]
@@ -4213,9 +3989,7 @@ async def test_user_update_invalidates_both_the_old_and_new_permission_rows(mock
user_id="target-user",
object_permission={"mcp_tool_permissions": {"github": ["list_issues"]}},
),
- user_api_key_dict=UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
deleted = {call.kwargs["key"] for call in cache.async_delete_cache.call_args_list}
@@ -4246,9 +4020,7 @@ async def test_non_admin_cannot_clear_their_own_mcp_entitlement(mocker):
with pytest.raises(HTTPException) as exc:
await _update_single_user_helper(
user_request=UpdateUserRequest(user_id="target-user", object_permission={}),
- user_api_key_dict=UserAPIKeyAuth(
- user_id="target-user", user_role=LitellmUserRoles.INTERNAL_USER
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="target-user", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert exc.value.status_code == 403
@@ -4276,9 +4048,7 @@ async def test_non_admin_cannot_rewrite_their_own_mcp_entitlement(mocker):
user_id="target-user",
object_permission={"mcp_servers": [], "mcp_tool_permissions": {}},
),
- user_api_key_dict=UserAPIKeyAuth(
- user_id="target-user", user_role=LitellmUserRoles.INTERNAL_USER
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="target-user", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert exc.value.status_code == 403
@@ -4295,9 +4065,7 @@ async def test_new_user_persists_the_requested_mcp_entitlement(mocker):
return_value=SimpleNamespace(object_permission_id="perm-created")
)
mock_prisma_client.db.litellm_mcpservertable.find_many = mocker.AsyncMock(return_value=[])
- mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
- return_value=None
- )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable.count = mocker.AsyncMock(return_value=0)
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mocker.patch(
@@ -4306,9 +4074,7 @@ async def test_new_user_persists_the_requested_mcp_entitlement(mocker):
)
mock_generate = mocker.patch(
"litellm.proxy.management_endpoints.internal_user_endpoints.generate_key_helper_fn",
- new=mocker.AsyncMock(
- return_value={"user_id": "new-human", "token": "sk-x", "expires": None}
- ),
+ new=mocker.AsyncMock(return_value={"user_id": "new-human", "token": "sk-x", "expires": None}),
)
mocker.patch(
"litellm.proxy.hooks.user_management_event_hooks.UserManagementEventHooks.async_user_created_hook",
@@ -4320,9 +4086,7 @@ async def test_new_user_persists_the_requested_mcp_entitlement(mocker):
user_id="new-human",
object_permission={"mcp_tool_permissions": {"github": ["list_issues"]}},
),
- user_api_key_dict=UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
created = mock_prisma_client.db.litellm_objectpermissiontable.create.call_args.kwargs["data"]
@@ -4364,16 +4128,12 @@ async def test_user_info_v2_returns_the_mcp_entitlement(mocker):
response = await user_info_v2(
request=SimpleNamespace(query_params={}),
user_id="human-1",
- user_api_key_dict=UserAPIKeyAuth(
- user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
- ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert response.object_permission is not None
assert response.object_permission.mcp_servers == ["github"]
- assert response.object_permission.mcp_tool_permissions == {
- "github": ["list_issues"]
- }
+ assert response.object_permission.mcp_tool_permissions == {"github": ["list_issues"]}
@pytest.mark.asyncio
@@ -4389,9 +4149,7 @@ async def test_user_info_v2_returns_the_mcp_entitlement(mocker):
],
ids=["supplied", "omitted", "empty"],
)
-async def test_user_new_persists_model_max_budget(
- monkeypatch, model_max_budget, expected_written
-):
+async def test_user_new_persists_model_max_budget(monkeypatch, model_max_budget, expected_written):
"""
/user/new used to echo model_max_budget back while writing {} to the user row,
so a per-model budget looked configured and was read by nothing.
@@ -4553,3 +4311,90 @@ async def test_user_update_rejects_breached_password(_admin_prisma):
assert exc_info.value.code == "400"
assert "data breaches" in exc_info.value.message
_admin_prisma.db.litellm_usertable.find_first.assert_not_called()
+
+
+def _hibp_client_with_handler(handler) -> AsyncHTTPHandler:
+ """A real AsyncHTTPHandler over httpx.MockTransport (the DI seam used
+ throughout test_password_policy.py), so no network is touched."""
+ http_handler = AsyncHTTPHandler()
+ http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ return http_handler
+
+
+@pytest.mark.asyncio
+async def test_bulk_update_breached_password_fails_only_that_user(_admin_prisma, mocker):
+ """In a bulk batch, a breached password fails only its own entry, before
+ any DB write for it; sibling entries with acceptable passwords persist."""
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ bulk_update_processed_users,
+ )
+
+ breached = "Br3ached!Passw0rd"
+ clean = "NewP@ssw0rd123"
+ breached_sha1 = hashlib.sha1(breached.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path == f"/range/{breached_sha1[:5]}":
+ return httpx.Response(200, text=f"{breached_sha1[5:]}:1387")
+ return httpx.Response(200, text="0000000000000000000000000000000000A:1")
+
+ mock_prisma_client = _admin_prisma
+ existing_user = mocker.MagicMock()
+ existing_user.model_dump.return_value = {"user_id": "user-clean"}
+ existing_user.user_id = "user-clean"
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
+ mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "user-clean"})
+ mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
+
+ admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
+ response = await bulk_update_processed_users(
+ users_to_update=[
+ UpdateUserRequest(user_id="user-breached", password=breached),
+ UpdateUserRequest(user_id="user-clean", password=clean),
+ ],
+ user_api_key_dict=admin_caller,
+ hibp_client=_hibp_client_with_handler(handler),
+ )
+
+ assert response.successful_updates == 1
+ assert response.failed_updates == 1
+ by_user = {r.user_id: r for r in response.results}
+ assert by_user["user-breached"].success is False
+ assert "data breaches" in by_user["user-breached"].error
+ assert by_user["user-clean"].success is True
+ (write_call,) = mock_prisma_client.update_data.call_args_list
+ assert write_call.kwargs["user_id"] == "user-clean"
+
+
+@pytest.mark.asyncio
+async def test_bulk_update_screens_shared_password_with_single_lookup(_admin_prisma, mocker):
+ """A batch where every user gets the same password costs one HIBP lookup,
+ not one per user (the serial per-user checks this regresses against)."""
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ bulk_update_processed_users,
+ )
+
+ lookup_count = 0
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ nonlocal lookup_count
+ lookup_count += 1
+ return httpx.Response(200, text="0000000000000000000000000000000000A:1")
+
+ mock_prisma_client = _admin_prisma
+ existing_user = mocker.MagicMock()
+ existing_user.model_dump.return_value = {"user_id": "user-0"}
+ existing_user.user_id = "user-0"
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
+ mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "user-0"})
+ mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
+
+ admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
+ response = await bulk_update_processed_users(
+ users_to_update=[UpdateUserRequest(user_id=f"user-{i}", password="NewP@ssw0rd123") for i in range(5)],
+ user_api_key_dict=admin_caller,
+ hibp_client=_hibp_client_with_handler(handler),
+ )
+
+ assert response.successful_updates == 5
+ assert lookup_count == 1
From 1d18d11fcf69388787b824b4d519e9855eede23e Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Tue, 8 Sep 2026 16:41:36 +0200
Subject: [PATCH 07/16] Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---
litellm/proxy/management_endpoints/internal_user_endpoints.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 58a8ba0b09d..4f037ffd43d 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -574,7 +574,7 @@ async def new_user(
# generate_key_helper_fn only forwards object_permission_id, so without this the entitlement
# the caller sent would be dropped on the floor.
data_json = await _set_object_permission(data_json=data_json, prisma_client=prisma_client)
- data_json.pop("password", None) # always None: NewUserRequest.password_not_supported rejects any other value
+ data_json.pop("password", None)
teams = data.teams
if teams is None:
teams = check_if_default_team_set()
From 5bb2c9e76f565bb4c4fb82ec527841a6340862e2 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Wed, 9 Sep 2026 13:46:29 +0200
Subject: [PATCH 08/16] fix(auth): annotate the strict-rule suppressions the
merged gates now count
The staging merge brought BLE001 into the strict ruff set and lowered the
LIT002 ceiling, so the HIBP fail-open except and the params/headers dicts
in password_policy.py now need their noqa and mutable-ok reasons. The
headers dict moves to an annotated Final so the suppression fits the line
limit.
---
litellm/proxy/auth/password_policy.py | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/litellm/proxy/auth/password_policy.py b/litellm/proxy/auth/password_policy.py
index c27f276252a..7f06a0993d3 100644
--- a/litellm/proxy/auth/password_policy.py
+++ b/litellm/proxy/auth/password_policy.py
@@ -110,7 +110,7 @@ def validate_password_policy(password: str, general_settings: Mapping[str, objec
def _hibp_client() -> AsyncHTTPHandler:
return get_async_httpx_client(
llm_provider=httpxSpecialProvider.PasswordBreachCheck,
- params={"timeout": HIBP_TIMEOUT_SECONDS},
+ params={"timeout": HIBP_TIMEOUT_SECONDS}, # mutable-ok: callee takes a bare dict (PEP 589)
)
@@ -125,14 +125,18 @@ def _is_suffix_in_range_response(response_body: str, hash_suffix: str) -> bool:
async def _is_password_breached(password: str, client: AsyncHTTPHandler) -> bool:
# usedforsecurity=False: SHA-1 is only a lookup key into the HIBP dataset, so no security property rests on it
sha1_hex: Final = hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+ headers: Final = { # mutable-ok: callee takes a bare dict (PEP 589)
+ "Add-Padding": "true",
+ "User-Agent": f"litellm-proxy/{version}",
+ }
try:
response: Final = await client.get(
f"{HIBP_RANGE_API_BASE}/{sha1_hex[:5]}",
- headers={"Add-Padding": "true", "User-Agent": f"litellm-proxy/{version}"},
+ headers=headers,
)
response.raise_for_status()
breached: Final = _is_suffix_in_range_response(response.text, sha1_hex[5:])
- except Exception as e:
+ except Exception as e: # noqa: BLE001 # fail-open: any HIBP failure skips the check, never breaks the caller
verbose_proxy_logger.warning("Breached-password check skipped, HIBP lookup failed: %s", e)
return False
return breached
From d79a893e3776923ac33f802975e01e2557e130a5 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Fri, 4 Sep 2026 09:02:34 +0200
Subject: [PATCH 09/16] feat(auth): add self-service change-password endpoint
Admin password sets on /user/update and per-user /user/bulk_update stay
supported and policy-enforced. The request model hides the password from
repr so management alerts never format the plaintext, and the all_users
bulk path rejects passwords instead of writing one plaintext value to
every row.
---
litellm/proxy/_types.py | 14 +-
litellm/proxy/auth/route_checks.py | 17 +-
.../internal_user_endpoints.py | 12 +-
.../password_endpoints.py | 117 ++++++++
litellm/proxy/proxy_server.py | 4 +
.../proxy/auth/test_route_checks.py | 213 ++++++-------
.../test_internal_user_endpoints.py | 29 ++
.../test_password_endpoints.py | 283 ++++++++++++++++++
tests/test_litellm/proxy/test__types.py | 26 ++
.../ChangePasswordForm.integration.test.tsx | 68 +++++
.../change-password/ChangePasswordForm.tsx | 100 +++++++
.../app/(dashboard)/change-password/page.tsx | 7 +
.../app/(dashboard)/hooks/useAuthorized.ts | 1 +
.../Navbar/UserDropdown/UserDropdown.test.tsx | 52 +++-
.../Navbar/UserDropdown/UserDropdown.tsx | 23 +-
.../SidebarAccountMenu.test.tsx | 43 +++
.../SidebarAccountMenu/SidebarAccountMenu.tsx | 24 +-
.../src/components/networking.tsx | 14 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 78 ++++-
19 files changed, 1004 insertions(+), 121 deletions(-)
create mode 100644 litellm/proxy/management_endpoints/password_endpoints.py
create mode 100644 tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/change-password/page.tsx
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 9b38b12ab07..7731ca736d5 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -864,6 +864,7 @@ class LiteLLMRoutes(enum.Enum):
"/organization/daily/activity",
"/user/available_roles", # read-only role metadata; any authenticated user may read
"/user/list", # org admins checked in endpoint; non-admins get 403
+ "/user/password/change", # endpoint only ever writes the caller's own row
"/model/{model_id}/update",
"/prompt/list",
"/prompt/info",
@@ -1841,7 +1842,8 @@ class NewUserResponse(GenerateKeyResponse):
class UpdateUserRequestNoUserIDorEmail(GenerateRequestBase): # shared with BulkUpdateUserRequest
- password: str | None = None
+ # repr=False keeps the plaintext out of management-endpoint alerts, which str() the request model
+ password: str | None = Field(default=None, repr=False)
spend: float | None = None
metadata: dict | None = None
user_alias: str | None = None
@@ -1871,6 +1873,16 @@ class UpdateUserRequest(UpdateUserRequestNoUserIDorEmail):
return values
+class ChangePasswordRequest(LiteLLMPydanticObjectBase):
+ current_password: str = Field(repr=False)
+ new_password: str = Field(repr=False)
+
+
+class ChangePasswordResponse(LiteLLMPydanticObjectBase):
+ user_id: str
+ message: str
+
+
class DeleteUserRequest(LiteLLMPydanticObjectBase):
user_ids: list[str] # required
diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py
index 953e3cf3e88..e4a36e73373 100644
--- a/litellm/proxy/auth/route_checks.py
+++ b/litellm/proxy/auth/route_checks.py
@@ -796,7 +796,8 @@ class RouteChecks:
in the codebase is automatically readable by Admin Viewer
without needing to remember to add it to an allowlist.
3. Unsafe HTTP method (POST/PUT/PATCH/DELETE):
- - Allow `/user/update` only when restricted to user_email/password.
+ - Allow `/user/update` only when restricted to user_email.
+ - Allow `/user/password/change` (endpoint only writes the caller's own row).
- Block all explicit writes in `_ADMIN_VIEWER_BLOCKED_WRITE_ROUTES`.
- Otherwise allow only if the route is in admin_viewer_routes /
global_spend_tracking_routes (legacy explicit-allow set).
@@ -816,10 +817,10 @@ class RouteChecks:
if request_data is not None and isinstance(request_data, dict):
_params_updated: Final = request_data.keys()
for param in _params_updated:
- if param not in ["user_email", "password"]:
+ if param != "user_email":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
- detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route} and updating invalid param: {param}. only user_email and password can be updated",
+ detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route} and updating invalid param: {param}. only user_email can be updated",
)
elif route in _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES or (
route.startswith("/key/") and route.endswith(_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES)
@@ -838,21 +839,25 @@ class RouteChecks:
return
# ── Unsafe HTTP method: explicit checks ──────────────────────────
- # Allow `/user/update` for self-service email / password change.
+ # Allow `/user/update` for self-service email change.
if route == "/user/update":
if request_data is not None and isinstance(request_data, dict):
for param in request_data:
- if param not in ["user_email", "password"]:
+ if param != "user_email":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"user not allowed to access this route, role= {_user_role}. "
f"Trying to access: {route} and updating invalid param: {param}. "
- "only user_email and password can be updated"
+ "only user_email can be updated"
),
)
return
+ # Self-service password change; the endpoint only writes the caller's own row.
+ if route == "/user/password/change":
+ return
+
# Hard-block known write routes regardless of HTTP method (defensive
# — these are POSTs in practice, but pinning them here protects
# against future GET-shaped writes).
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 4f037ffd43d..368d5ed0c10 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -1644,7 +1644,7 @@ async def user_update(
Parameters:
- user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
- user_email: Optional[str] - Specify a user email.
- - password: Optional[str] - Specify a user password.
+ - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change.
- user_alias: Optional[str] - A descriptive name for you to know who this user id refers to.
- teams: Optional[list] - specify a list of team id's a user belongs to.
- send_invite_email: Optional[bool] - Specify if an invite email should be sent.
@@ -1881,6 +1881,16 @@ async def bulk_user_update(
status_code=403,
detail="Only proxy admins can update all users at once.",
)
+ if data.user_updates.password is not None:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": (
+ "Setting one password for all users is not supported. "
+ "Use per-user updates via the 'users' list instead."
+ )
+ },
+ )
# Optimized path for updating all users directly in database
all_users_in_db: Final = await _user_table(prisma_client).find_many(order={"created_at": "desc"})
diff --git a/litellm/proxy/management_endpoints/password_endpoints.py b/litellm/proxy/management_endpoints/password_endpoints.py
new file mode 100644
index 00000000000..78e53d7a777
--- /dev/null
+++ b/litellm/proxy/management_endpoints/password_endpoints.py
@@ -0,0 +1,117 @@
+"""
+Self-service password management.
+
+/user/password/change
+
+Deliberately NOT wrapped in `management_endpoint_wrapper`: the wrapper emits
+request kwargs to OTEL spans, which would log plaintext passwords. The audit
+signal is emitted by hand below, with field names only, never values.
+"""
+
+from typing import TYPE_CHECKING, Final
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from litellm._logging import verbose_proxy_logger
+from litellm.proxy._types import (
+ ChangePasswordRequest,
+ ChangePasswordResponse,
+ CommonProxyErrors,
+ LitellmTableNames,
+ UserAPIKeyAuth,
+)
+from litellm.proxy.auth.password_policy import validate_password_not_breached, validate_password_policy
+from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.management_helpers.audit_logs import create_object_audit_log
+from litellm.proxy.utils import hash_password, verify_password
+from litellm.repositories.prisma_protocols import TableActions
+from litellm.repositories.user_repository import UserRepository
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
+ from litellm.proxy.utils import PrismaClient
+
+router: Final = APIRouter()
+
+_PASSWORD_CHANGED_AUDIT_VALUES: Final = '{"fields_changed": ["password"]}'
+
+
+def _user_table(
+ prisma_client: "PrismaClient | None",
+) -> "TableActions[prisma_models.LiteLLM_UserTable]":
+ user_table: Final[TableActions[prisma_models.LiteLLM_UserTable]] = UserRepository(prisma_client).table
+ return user_table
+
+
+@router.post(
+ "/user/password/change",
+ tags=["Internal User management"],
+ dependencies=(Depends(user_api_key_auth),),
+)
+async def change_password(
+ data: ChangePasswordRequest,
+ user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+) -> ChangePasswordResponse:
+ """
+ Change the calling user's own password.
+
+ Requires the current password. The new password must satisfy the
+ configured password policy (`general_settings.password_policy_*`: minimum
+ length, character classes, and, when enabled, breached-password screening
+ via haveibeenpwned.com).
+
+ Parameters:
+ - current_password: str - The user's current password.
+ - new_password: str - The password to change to.
+ """
+ from litellm.proxy.proxy_server import general_settings, litellm_proxy_admin_name, prisma_client
+
+ if prisma_client is None:
+ raise HTTPException(
+ status_code=500,
+ detail={"error": CommonProxyErrors.db_not_connected_error.value},
+ )
+
+ user_id: Final = user_api_key_dict.user_id
+ if user_id is None:
+ raise HTTPException(
+ status_code=400,
+ detail={"error": "No user is associated with this session, so there is no password to change."},
+ )
+
+ user_row: Final = await _user_table(prisma_client).find_first(where={"user_id": user_id})
+ stored_password: Final = user_row.password if user_row is not None else None
+ if stored_password is None:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": (
+ "This account has no password set, so there is no password to change. "
+ "Passwords are set through an invitation link (POST /invitation/new)."
+ )
+ },
+ )
+
+ if not verify_password(data.current_password, stored_password):
+ raise HTTPException(status_code=400, detail={"error": "Current password is incorrect."})
+
+ validate_password_policy(data.new_password, general_settings)
+ await validate_password_not_breached(data.new_password, general_settings)
+
+ await _user_table(prisma_client).update(
+ where={"user_id": user_id},
+ data={"password": hash_password(data.new_password)},
+ )
+
+ verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id)
+ await create_object_audit_log(
+ object_id=user_id,
+ action="updated",
+ litellm_changed_by=None,
+ user_api_key_dict=user_api_key_dict,
+ litellm_proxy_admin_name=litellm_proxy_admin_name,
+ table_name=LitellmTableNames.USER_TABLE_NAME,
+ after_value=_PASSWORD_CHANGED_AUDIT_VALUES,
+ )
+ return ChangePasswordResponse(user_id=user_id, message="Password updated successfully.")
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index d2b486b4410..d803e8fd4be 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -549,6 +549,9 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
from litellm.proxy.management_endpoints.organization_endpoints import (
router as organization_router,
)
+from litellm.proxy.management_endpoints.password_endpoints import (
+ router as password_management_router,
+)
from litellm.proxy.management_endpoints.router_settings_endpoints import (
router as router_settings_router,
)
@@ -18765,6 +18768,7 @@ app.include_router(pass_through_router)
app.include_router(health_router)
app.include_router(key_management_router)
app.include_router(internal_user_router)
+app.include_router(password_management_router)
app.include_router(team_router)
app.include_router(ui_sso_router)
app.include_router(organization_router)
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index c8b3d789665..4f58e3c86ff 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -2,7 +2,6 @@ import os
from datetime import datetime
from unittest.mock import MagicMock, patch
-
import pytest
from fastapi import HTTPException, Request
@@ -38,7 +37,7 @@ def test_non_admin_config_update_route_rejected():
request.query_params = {}
# Test that calling /config/update route raises HTTPException with 403 status
- with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
@@ -49,9 +48,8 @@ def test_non_admin_config_update_route_rejected():
)
# Verify the exception is raised with the correct message
- assert (
- "Only proxy admin can be used to generate, delete, update info for new keys/users/teams"
- in str(exc_info.value)
+ assert "Only proxy admin can be used to generate, delete, update info for new keys/users/teams" in str(
+ exc_info.value
)
assert "Route=/config/update" in str(exc_info.value)
assert "Your role=internal_user" in str(exc_info.value)
@@ -130,7 +128,7 @@ def test_user_banner_update_rejected_for_non_admin():
request = MagicMock(spec=Request)
request.query_params = {}
- with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
@@ -703,9 +701,7 @@ def test_virtual_key_llm_api_route_includes_passthrough_prefix(route):
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["llm_api_routes"])
- result = RouteChecks.is_virtual_key_allowed_to_call_route(
- route=route, valid_token=valid_token
- )
+ result = RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token)
assert result is True
@@ -730,9 +726,7 @@ def test_virtual_key_llm_api_routes_allows_google_routes(route):
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["llm_api_routes"])
- result = RouteChecks.is_virtual_key_allowed_to_call_route(
- route=route, valid_token=valid_token
- )
+ result = RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token)
assert result is True
@@ -802,18 +796,14 @@ def test_google_routes_with_dynamic_model_names_accessible_to_internal_users():
)
# If no exception is raised, the test passes
except Exception as e:
- pytest.fail(
- f"Internal user should be able to access Google generateContent route. Got error: {str(e)}"
- )
+ pytest.fail(f"Internal user should be able to access Google generateContent route. Got error: {e!s}")
def test_virtual_key_allowed_routes_with_multiple_litellm_routes_member_names():
"""Test that virtual key works with multiple LiteLLMRoutes member names in allowed_routes"""
# Create a UserAPIKeyAuth with multiple LiteLLMRoutes member names
- valid_token = UserAPIKeyAuth(
- user_id="test_user", allowed_routes=["openai_routes", "info_routes"]
- )
+ valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["openai_routes", "info_routes"])
# Test that routes from both groups are allowed
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
@@ -867,13 +857,9 @@ def test_virtual_key_allowed_routes_with_no_member_names_only_explicit():
)
# Test that explicit routes are allowed
- result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
- route="/chat/completions", valid_token=valid_token
- )
+ result1 = RouteChecks.is_virtual_key_allowed_to_call_route(route="/chat/completions", valid_token=valid_token)
- result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
- route="/custom/route", valid_token=valid_token
- )
+ result2 = RouteChecks.is_virtual_key_allowed_to_call_route(route="/custom/route", valid_token=valid_token)
assert result1 is True
assert result2 is True
@@ -1241,9 +1227,7 @@ def test_virtual_key_without_llm_api_routes_cannot_access_pass_through():
)
assert exc_info.value.status_code == 403
- assert "Virtual key is not allowed to call this route" in str(
- exc_info.value.detail
- )
+ assert "Virtual key is not allowed to call this route" in str(exc_info.value.detail)
def test_check_passthrough_route_access_key_metadata_exact_match():
@@ -1702,9 +1686,7 @@ def test_videos_route_accessible_to_internal_users():
)
# If no exception is raised, the test passes
except Exception as e:
- pytest.fail(
- f"Internal user should be able to access /v1/videos route. Got error: {str(e)}"
- )
+ pytest.fail(f"Internal user should be able to access /v1/videos route. Got error: {e!s}")
def test_videos_route_with_virtual_key_llm_api_routes():
@@ -1726,12 +1708,8 @@ def test_videos_route_with_virtual_key_llm_api_routes():
]
for route in test_routes:
- result = RouteChecks.is_virtual_key_allowed_to_call_route(
- route=route, valid_token=valid_token
- )
- assert (
- result is True
- ), f"Virtual key with llm_api_routes should be able to access {route}"
+ result = RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token)
+ assert result is True, f"Virtual key with llm_api_routes should be able to access {route}"
def test_non_proxy_admin_wildcard_allowed_routes():
@@ -1802,9 +1780,7 @@ def test_proxy_admin_viewer_can_access_global_spend_tags():
)
# If no exception is raised, the test passes
except Exception as e:
- pytest.fail(
- f"proxy_admin_viewer should be able to access /global/spend/tags route. Got error: {str(e)}"
- )
+ pytest.fail(f"proxy_admin_viewer should be able to access /global/spend/tags route. Got error: {e!s}")
# Routes returning proxy-wide spend across every team / customer / api_key.
@@ -1832,7 +1808,7 @@ def test_internal_user_blocked_from_global_spend_routes(route):
request = MagicMock(spec=Request)
request.query_params = {}
- with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
@@ -1861,7 +1837,7 @@ def test_internal_user_view_only_blocked_from_global_spend_routes(route):
request = MagicMock(spec=Request)
request.query_params = {}
- with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
@@ -1963,9 +1939,7 @@ def test_proxy_admin_viewer_can_access_audit_logs(route):
request_data={},
)
except Exception as e:
- pytest.fail(
- f"proxy_admin_viewer should be able to access {route} route. Got error: {str(e)}"
- )
+ pytest.fail(f"proxy_admin_viewer should be able to access {route} route. Got error: {e!s}")
# ── Admin Viewer parity: Logs page endpoints ──────────────────────────────────
@@ -2028,9 +2002,7 @@ def test_proxy_admin_viewer_can_access_logs_page_endpoints(route):
request_data={},
)
except Exception as e:
- pytest.fail(
- f"proxy_admin_viewer should be able to access {route}. Got error: {str(e)}"
- )
+ pytest.fail(f"proxy_admin_viewer should be able to access {route}. Got error: {e!s}")
@pytest.mark.parametrize(
@@ -2140,7 +2112,7 @@ def test_internal_user_blocked_from_admin_viewer_logs_routes(route):
if route not in INTERNAL_USER_BLOCKED_SUBSET:
return
- with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
@@ -2216,9 +2188,7 @@ def test_proxy_admin_viewer_can_access_settings_read_endpoints(route):
request_data={},
)
except Exception as e:
- pytest.fail(
- f"proxy_admin_viewer should be able to access {route}. Got error: {str(e)}"
- )
+ pytest.fail(f"proxy_admin_viewer should be able to access {route}. Got error: {e!s}")
# ── Admin Viewer parity: default-allow GET semantics ─────────────────────────
@@ -2417,9 +2387,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
)
local_file = os.path.abspath(local_file)
- spec = importlib.util.spec_from_file_location(
- "local_enterprise_route_checks", local_file
- )
+ spec = importlib.util.spec_from_file_location("local_enterprise_route_checks", local_file)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.EnterpriseRouteChecks
@@ -2430,9 +2398,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
EnterpriseRouteChecks = self._get_enterprise_route_checks()
with (
- patch.object(
- EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
- ),
+ patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
patch.object(
EnterpriseRouteChecks,
"is_management_routes_disabled",
@@ -2448,9 +2414,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
EnterpriseRouteChecks = self._get_enterprise_route_checks()
with (
- patch.object(
- EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
- ),
+ patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
patch.object(
EnterpriseRouteChecks,
"is_management_routes_disabled",
@@ -2466,9 +2430,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
EnterpriseRouteChecks = self._get_enterprise_route_checks()
with (
- patch.object(
- EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
- ),
+ patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
patch.object(
EnterpriseRouteChecks,
"is_management_routes_disabled",
@@ -2479,9 +2441,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
EnterpriseRouteChecks.should_call_route("/v1/chat/completions")
assert exc_info.value.status_code == 403
- assert "LLM API routes are disabled for this instance." in str(
- exc_info.value.detail
- )
+ assert "LLM API routes are disabled for this instance." in str(exc_info.value.detail)
@patch("litellm.proxy.proxy_server.premium_user", True)
def test_should_embeddings_still_blocked_when_llm_api_disabled(self):
@@ -2489,9 +2449,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
EnterpriseRouteChecks = self._get_enterprise_route_checks()
with (
- patch.object(
- EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
- ),
+ patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
patch.object(
EnterpriseRouteChecks,
"is_management_routes_disabled",
@@ -2509,9 +2467,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
EnterpriseRouteChecks = self._get_enterprise_route_checks()
with (
- patch.object(
- EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False
- ),
+ patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False),
patch.object(
EnterpriseRouteChecks,
"is_management_routes_disabled",
@@ -2530,9 +2486,7 @@ def test_route_in_additional_public_routes_wildcard_match():
from litellm.proxy.auth.auth_utils import route_in_additonal_public_routes
with (
- patch(
- "litellm.proxy.proxy_server.general_settings", {"public_routes": ["/api/*"]}
- ),
+ patch("litellm.proxy.proxy_server.general_settings", {"public_routes": ["/api/*"]}),
patch("litellm.proxy.proxy_server.premium_user", True),
):
# Wildcard should match subpaths
@@ -2624,7 +2578,7 @@ def test_non_admin_non_team_admin_cannot_access_config_update_but_can_attempt_re
)
# /config/update is still blocked
- with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
@@ -2712,8 +2666,6 @@ def test_available_roles_accessible_to_non_admin_users(user_role):
# ── _user_is_org_admin tests ──────────────────────────────────────────────────
-
-
def _make_org_admin_user(org_id: str) -> LiteLLM_UserTable:
membership = LiteLLM_OrganizationMembershipTable(
user_id="org-admin-user",
@@ -2836,9 +2788,7 @@ async def test_add_team_org_context_noop_when_org_id_already_present():
raise AssertionError("must not resolve when organization_id is present")
body = {"team_id": "team-1", "organization_id": "org-explicit"}
- out = await add_team_org_context_to_request_body(
- route="/team/update", request_body=body, fetch_team_org_id=fetch
- )
+ out = await add_team_org_context_to_request_body(route="/team/update", request_body=body, fetch_team_org_id=fetch)
assert out == body
@@ -2850,9 +2800,7 @@ async def test_add_team_org_context_noop_for_other_routes():
raise AssertionError("must not resolve for a non-opted-in route")
body = {"team_id": "team-1"}
- out = await add_team_org_context_to_request_body(
- route="/team/delete", request_body=body, fetch_team_org_id=fetch
- )
+ out = await add_team_org_context_to_request_body(route="/team/delete", request_body=body, fetch_team_org_id=fetch)
assert out == body
@@ -2865,9 +2813,7 @@ async def test_add_team_org_context_noop_when_team_has_no_org():
return None
body = {"team_id": "team-1"}
- out = await add_team_org_context_to_request_body(
- route="/team/update", request_body=body, fetch_team_org_id=fetch
- )
+ out = await add_team_org_context_to_request_body(route="/team/update", request_body=body, fetch_team_org_id=fetch)
assert out == body
@@ -3151,9 +3097,7 @@ async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
# Removing the endpoint should clean up openai_routes
# remove_endpoint_routes takes endpoint_id (UUID portion of
# the route key "{id}:exact:{path}:{methods}")
- registered = (
- InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
- )
+ registered = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
endpoint_ids = {k.split(":")[0] for k in registered}
for eid in endpoint_ids:
InitPassThroughEndpointHelpers.remove_endpoint_routes(eid)
@@ -3163,9 +3107,7 @@ async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
LiteLLMRoutes.openai_routes.value[:] = original_routes
# Clean up any routes registered during this test to avoid
# polluting the module-level _registered_pass_through_routes
- registered = (
- InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
- )
+ registered = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
for k in registered:
InitPassThroughEndpointHelpers.remove_endpoint_routes(k.split(":")[0])
@@ -3196,8 +3138,7 @@ def test_provider_name_substring_not_classified_as_llm_route(route):
from litellm.proxy.auth.route_checks import RouteChecks
assert RouteChecks.is_llm_api_route(route=route) is False, (
- f"{route!r} should NOT be classified as an LLM API route — "
- "provider-name substring match bypass"
+ f"{route!r} should NOT be classified as an LLM API route — provider-name substring match bypass"
)
@@ -3219,9 +3160,7 @@ def test_legitimate_passthrough_routes_still_classified_as_llm_route(route):
"""Legitimate passthrough routes must still pass is_llm_api_route."""
from litellm.proxy.auth.route_checks import RouteChecks
- assert (
- RouteChecks.is_llm_api_route(route=route) is True
- ), f"{route!r} should be classified as an LLM API route"
+ assert RouteChecks.is_llm_api_route(route=route) is True, f"{route!r} should be classified as an LLM API route"
@pytest.mark.parametrize(
@@ -3279,7 +3218,7 @@ def test_internal_user_blocked_from_search_tool_writes(route):
request = MagicMock(spec=Request)
request.query_params = {}
- with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
@@ -3655,12 +3594,7 @@ def test_agent_inference_routes_stay_llm_api(route):
def test_agent_routes_union_still_covers_both_halves(route):
"""Keys configured with allowed_routes=["agent_routes"] must keep both halves."""
- assert (
- RouteChecks.check_route_access(
- route=route, allowed_routes=LiteLLMRoutes.agent_routes.value
- )
- is True
- )
+ assert RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.agent_routes.value) is True
@pytest.mark.parametrize("route", AGENT_MANAGEMENT_ROUTES)
@@ -3714,6 +3648,75 @@ def test_agent_registry_route_gate_open_to_non_admin_roles(user_role, method, ro
valid_token=valid_token,
request_data={},
)
+
+
+def test_proxy_admin_viewer_user_update_password_param_rejected():
+ """The self-service /user/update password carve-out is closed: non-admins
+ change their own password through /user/password/change, which verifies
+ the current password. Admin password sets don't pass through this check."""
+ with pytest.raises(HTTPException) as exc_info:
+ RouteChecks._check_proxy_admin_viewer_access(
+ route="/user/update",
+ _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
+ request_data={"password": "hunter2hunter2"},
+ )
+ assert exc_info.value.status_code == 403
+ assert "password" in str(exc_info.value.detail)
+
+
+def test_proxy_admin_viewer_user_update_user_email_still_allowed():
+ request = MagicMock(spec=Request)
+ request.method = "POST"
+
+ allowed = RouteChecks._check_proxy_admin_viewer_access(
+ route="/user/update",
+ _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
+ request_data={"user_email": "viewer@example.com"},
+ request=request,
+ )
+
+ assert allowed is None
+
+
+def test_proxy_admin_viewer_can_change_own_password():
+ request = MagicMock(spec=Request)
+ request.method = "POST"
+
+ allowed = RouteChecks._check_proxy_admin_viewer_access(
+ route="/user/password/change",
+ _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
+ request_data={"current_password": "a", "new_password": "b"},
+ request=request,
+ )
+
+ assert allowed is None
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [
+ LitellmUserRoles.INTERNAL_USER.value,
+ LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
+ ],
+)
+def test_non_admin_roles_can_change_own_password(user_role):
+ valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
+ request = MagicMock(spec=Request)
+ request.method = "POST"
+ request.query_params = {}
+
+ allowed = RouteChecks.non_proxy_admin_allowed_routes_check(
+ user_obj=LiteLLM_UserTable(user_id="test_user", user_role=user_role),
+ _user_role=user_role,
+ route="/user/password/change",
+ request=request,
+ valid_token=valid_token,
+ request_data={"current_password": "a", "new_password": "b"},
+ )
+
+ assert allowed is None
+
+
TEAM_CALLBACK_ROUTES = (
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback",
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback/langfuse",
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index 024d7b8300f..a443235fe15 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -4313,6 +4313,35 @@ async def test_user_update_rejects_breached_password(_admin_prisma):
_admin_prisma.db.litellm_usertable.find_first.assert_not_called()
+@pytest.mark.asyncio
+async def test_bulk_update_all_users_rejects_a_password(_admin_prisma):
+ """The all_users fast path writes user_updates straight to update_many,
+ bypassing _update_single_user_helper. A password riding along would be
+ stored as unvalidated plaintext on every row, so it must be rejected
+ before any DB access."""
+ from fastapi import HTTPException
+
+ from litellm.proxy._types import UpdateUserRequestNoUserIDorEmail
+ from litellm.proxy.management_endpoints.internal_user_endpoints import bulk_user_update
+ from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
+ BulkUpdateUserRequest,
+ )
+
+ data = BulkUpdateUserRequest(
+ all_users=True,
+ user_updates=UpdateUserRequestNoUserIDorEmail(password="Str0ng!Passw0rd"),
+ )
+ admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await bulk_user_update(data=data, user_api_key_dict=admin_caller)
+
+ assert exc_info.value.status_code == 400
+ assert "not supported" in str(exc_info.value.detail)
+ _admin_prisma.db.litellm_usertable.find_many.assert_not_called()
+ _admin_prisma.db.litellm_usertable.update_many.assert_not_called()
+
+
def _hibp_client_with_handler(handler) -> AsyncHTTPHandler:
"""A real AsyncHTTPHandler over httpx.MockTransport (the DI seam used
throughout test_password_policy.py), so no network is touched."""
diff --git a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
new file mode 100644
index 00000000000..c7e0a385ae9
--- /dev/null
+++ b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
@@ -0,0 +1,283 @@
+"""
+Tests for POST /user/password/change (litellm/proxy/management_endpoints/password_endpoints.py).
+
+HIBP traffic is intercepted with respx; no test here touches the network.
+"""
+
+import hashlib
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+import respx
+from fastapi import HTTPException
+
+from litellm.proxy._types import LitellmTableNames, ProxyErrorTypes, ProxyException, UserAPIKeyAuth
+from litellm.proxy.management_endpoints.password_endpoints import change_password
+from litellm.proxy.utils import hash_password, verify_password
+
+CURRENT_PASSWORD = "OldP@ssw0rd-2026"
+NEW_PASSWORD = "NewP@ssw0rd-2026"
+
+_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
+
+
+def _make_user_row(password: str | None) -> MagicMock:
+ user = MagicMock()
+ user.user_id = "user-123"
+ user.password = password
+ return user
+
+
+def _make_prisma(user: MagicMock | None) -> MagicMock:
+ prisma = MagicMock()
+ prisma.db.litellm_usertable.find_first = AsyncMock(return_value=user)
+ prisma.db.litellm_usertable.update = AsyncMock(return_value=user)
+ return prisma
+
+
+def _caller(user_id: str | None = "user-123") -> UserAPIKeyAuth:
+ return UserAPIKeyAuth(user_id=user_id)
+
+
+def _hibp_url_for(password: str) -> str:
+ sha1 = hashlib.sha1(password.encode(), usedforsecurity=False).hexdigest().upper()
+ return f"https://api.pwnedpasswords.com/range/{sha1[:5]}"
+
+
+def _hibp_suffix_for(password: str) -> str:
+ return hashlib.sha1(password.encode(), usedforsecurity=False).hexdigest().upper()[5:]
+
+
+@pytest.mark.asyncio
+async def test_change_password_success_writes_new_scrypt_hash():
+ from litellm.proxy._types import ChangePasswordRequest
+
+ prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ response = await change_password(
+ data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(),
+ )
+
+ assert response.user_id == "user-123"
+ update_kwargs = prisma.db.litellm_usertable.update.call_args.kwargs
+ assert update_kwargs["where"] == {"user_id": "user-123"}
+ stored = update_kwargs["data"]["password"]
+ assert stored != NEW_PASSWORD
+ assert verify_password(NEW_PASSWORD, stored)
+
+
+@pytest.mark.asyncio
+async def test_change_password_rejects_wrong_current_password():
+ from litellm.proxy._types import ChangePasswordRequest
+
+ prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await change_password(
+ data=ChangePasswordRequest(current_password="not-the-password", new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(),
+ )
+
+ assert exc_info.value.status_code == 400
+ assert "Current password is incorrect" in exc_info.value.detail["error"]
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_change_password_rejects_session_without_user():
+ from litellm.proxy._types import ChangePasswordRequest
+
+ prisma = _make_prisma(user=None)
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await change_password(
+ data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(user_id=None),
+ )
+
+ assert exc_info.value.status_code == 400
+ prisma.db.litellm_usertable.find_first.assert_not_called()
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_change_password_rejects_account_without_password():
+ """SSO users and the env-credential admin have no DB password row to change."""
+ from litellm.proxy._types import ChangePasswordRequest
+
+ prisma = _make_prisma(_make_user_row(password=None))
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await change_password(
+ data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(),
+ )
+
+ assert exc_info.value.status_code == 400
+ assert "no password set" in exc_info.value.detail["error"]
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_change_password_enforces_min_length():
+ from litellm.proxy._types import ChangePasswordRequest
+
+ prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ with pytest.raises(ProxyException) as exc_info:
+ await change_password(
+ data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password="Short1!"),
+ user_api_key_dict=_caller(),
+ )
+
+ assert exc_info.value.code == "400"
+ assert exc_info.value.type == ProxyErrorTypes.validation_error
+ assert exc_info.value.param == "password"
+ assert "at least 12 characters" in exc_info.value.message
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_change_password_rejects_breached_password():
+ """With the default policy, the new password is screened against HIBP."""
+ from litellm.proxy._types import ChangePasswordRequest
+
+ breached_password = "Password123!"
+ respx.get(_hibp_url_for(breached_password)).mock(
+ return_value=httpx.Response(200, text=f"{_hibp_suffix_for(breached_password)}:1")
+ )
+ prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ with pytest.raises(ProxyException) as exc_info:
+ await change_password(
+ data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=breached_password),
+ user_api_key_dict=_caller(),
+ )
+
+ assert exc_info.value.code == "400"
+ assert exc_info.value.type == ProxyErrorTypes.validation_error
+ assert exc_info.value.param == "password"
+ assert "data breaches" in exc_info.value.message
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_change_password_verifies_current_password_before_hibp_lookup():
+ """A caller who fails current-password verification must not trigger any
+ HIBP traffic. The HIBP check fails open on errors, so an unmocked lookup
+ could not prove ordering; instead the route is registered and asserted
+ uncalled."""
+ from litellm.proxy._types import ChangePasswordRequest
+
+ hibp_route = respx.get(_hibp_url_for(NEW_PASSWORD)).mock(return_value=httpx.Response(200, text=""))
+ prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await change_password(
+ data=ChangePasswordRequest(current_password="not-the-password", new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(),
+ )
+
+ assert exc_info.value.status_code == 400
+ assert "Current password is incorrect" in exc_info.value.detail["error"]
+ assert not hibp_route.called
+ prisma.db.litellm_usertable.update.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_change_password_success_emits_redacted_audit_log():
+ """A successful change must land in the audit trail as field names only;
+ the plaintext passwords must never reach the audit call."""
+ from litellm.proxy._types import ChangePasswordRequest
+
+ prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
+ audit_mock = AsyncMock()
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock),
+ ):
+ await change_password(
+ data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(),
+ )
+
+ audit_mock.assert_awaited_once()
+ audit_kwargs = audit_mock.await_args.kwargs
+ assert audit_kwargs["object_id"] == "user-123"
+ assert audit_kwargs["action"] == "updated"
+ assert audit_kwargs["table_name"] == LitellmTableNames.USER_TABLE_NAME
+ assert audit_kwargs["after_value"] == '{"fields_changed": ["password"]}'
+ assert CURRENT_PASSWORD not in str(audit_kwargs)
+ assert NEW_PASSWORD not in str(audit_kwargs)
+
+
+@pytest.mark.asyncio
+async def test_change_password_failure_emits_no_audit_log():
+ from litellm.proxy._types import ChangePasswordRequest
+
+ prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
+ audit_mock = AsyncMock()
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock),
+ ):
+ with pytest.raises(HTTPException):
+ await change_password(
+ data=ChangePasswordRequest(current_password="not-the-password", new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(),
+ )
+
+ audit_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_change_password_requires_db():
+ from litellm.proxy._types import ChangePasswordRequest
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await change_password(
+ data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
+ user_api_key_dict=_caller(),
+ )
+
+ assert exc_info.value.status_code == 500
diff --git a/tests/test_litellm/proxy/test__types.py b/tests/test_litellm/proxy/test__types.py
index 8f9c44d7a38..4ea30c7bf87 100644
--- a/tests/test_litellm/proxy/test__types.py
+++ b/tests/test_litellm/proxy/test__types.py
@@ -5,6 +5,7 @@ from pydantic import ValidationError
from litellm.proxy._types import (
ROLES_WITHIN_ORG,
+ ChangePasswordRequest,
GenerateKeyRequest,
KeyRequest,
LiteLLM_AuditLogs,
@@ -293,3 +294,28 @@ def test_new_user_request_loudly_rejects_a_password():
def test_new_user_request_without_password_still_works():
request = NewUserRequest(user_email="alice@example.com")
assert request.password is None
+
+
+def test_update_user_request_accepts_a_password():
+ """Admins set user passwords through /user/update; the value must survive
+ model validation so the endpoint can policy-check and hash it."""
+ request = UpdateUserRequest(user_id="user-123", password="hunter2hunter2")
+ assert request.password == "hunter2hunter2"
+
+
+def test_update_user_request_password_hidden_from_repr():
+ """management_endpoint_wrapper string-formats endpoint kwargs into Slack
+ alerts, so the model's repr/str must never contain the plaintext password."""
+ request = UpdateUserRequest(user_id="user-123", password="hunter2hunter2")
+ assert "hunter2hunter2" not in repr(request)
+ assert "hunter2hunter2" not in str(request)
+
+
+def test_change_password_request_passwords_hidden_from_repr():
+ """Any accidental str()/repr() of the request model (debug logs, exception
+ handlers, a future management_endpoint_wrapper) must never contain either
+ plaintext password."""
+ request = ChangePasswordRequest(current_password="hunter2hunter2", new_password="NewP@ssw0rd-2026")
+ for rendered in (repr(request), str(request)):
+ assert "hunter2hunter2" not in rendered
+ assert "NewP@ssw0rd-2026" not in rendered
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
new file mode 100644
index 00000000000..e78f170cf0c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ChangePasswordForm from "./ChangePasswordForm";
+
+const mockChangePasswordCall = vi.fn();
+const mockToastSuccess = vi.fn();
+
+vi.mock("@/components/networking", () => ({
+ changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args),
+}));
+
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => ({ accessToken: "sk-session-token" }),
+}));
+
+vi.mock("@/lib/toast", () => ({
+ toast: {
+ success: (...args: unknown[]) => mockToastSuccess(...args),
+ fromError: vi.fn(),
+ },
+}));
+
+const fillForm = (values: { current: string; next: string; confirm: string }) => {
+ fireEvent.change(screen.getByLabelText("Current Password"), { target: { value: values.current } });
+ fireEvent.change(screen.getByLabelText("New Password"), { target: { value: values.next } });
+ fireEvent.change(screen.getByLabelText("Confirm New Password"), { target: { value: values.confirm } });
+};
+
+const submit = () => fireEvent.click(screen.getByRole("button", { name: "Change Password" }));
+
+describe("ChangePasswordForm", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("sends the current and new password to the change endpoint and resets on success", async () => {
+ mockChangePasswordCall.mockResolvedValue({ user_id: "user-123", message: "Password updated successfully." });
+ render( );
+
+ fillForm({ current: "OldP@ssw0rd-2026", next: "NewP@ssw0rd-2026", confirm: "NewP@ssw0rd-2026" });
+ submit();
+
+ expect(await screen.findByLabelText("Current Password")).toHaveValue("");
+ expect(mockChangePasswordCall).toHaveBeenCalledWith("sk-session-token", "OldP@ssw0rd-2026", "NewP@ssw0rd-2026");
+ expect(mockToastSuccess).toHaveBeenCalled();
+ });
+
+ it("blocks submission when the confirmation does not match", async () => {
+ render( );
+
+ fillForm({ current: "OldP@ssw0rd-2026", next: "NewP@ssw0rd-2026", confirm: "Different-2026" });
+ submit();
+
+ expect(await screen.findByText("New passwords do not match")).toBeInTheDocument();
+ expect(mockChangePasswordCall).not.toHaveBeenCalled();
+ });
+
+ it("shows the proxy's rejection message unwrapped", async () => {
+ mockChangePasswordCall.mockRejectedValue(new Error("{'error': 'Current password is incorrect.'}"));
+ render( );
+
+ fillForm({ current: "wrong-password", next: "NewP@ssw0rd-2026", confirm: "NewP@ssw0rd-2026" });
+ submit();
+
+ expect(await screen.findByText("Current password is incorrect.")).toBeInTheDocument();
+ expect(mockToastSuccess).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
new file mode 100644
index 00000000000..4c51b7f3d15
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import React, { useState } from "react";
+import { CircleAlert } from "lucide-react";
+import { z } from "zod/v4";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { Alert, AlertTitle } from "@/components/shared/Alert";
+import { PasswordInput } from "@/components/shared/PasswordInput";
+import { FormField } from "@/components/shared/form/FormField";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { FieldGroup } from "@/components/ui/field";
+import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
+import { changePasswordCall } from "@/components/networking";
+import { extractProxyErrorMessage } from "@/lib/http/client";
+import { useZodForm } from "@/lib/forms/useZodForm";
+import { toast } from "@/lib/toast";
+
+const changePasswordSchema = z
+ .object({
+ currentPassword: z.string().min(1, "Current password is required"),
+ newPassword: z.string().min(1, "New password is required"),
+ confirmNewPassword: z.string().min(1, "Confirm your new password"),
+ })
+ .refine((values) => values.newPassword === values.confirmNewPassword, {
+ message: "New passwords do not match",
+ path: ["confirmNewPassword"],
+ });
+
+type ChangePasswordValues = z.infer;
+
+export function ChangePasswordForm() {
+ const { accessToken } = useAuthorized();
+ const form = useZodForm(changePasswordSchema, {
+ defaultValues: { currentPassword: "", newPassword: "", confirmNewPassword: "" },
+ });
+ const [isPending, setIsPending] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ const handleSubmit = async (values: ChangePasswordValues) => {
+ if (!accessToken) return;
+ setSubmitError(null);
+ setIsPending(true);
+ try {
+ await changePasswordCall(accessToken, values.currentPassword, values.newPassword);
+ toast.success("Password updated");
+ form.reset();
+ } catch (error) {
+ setSubmitError(extractProxyErrorMessage(error));
+ } finally {
+ setIsPending(false);
+ }
+ };
+
+ return (
+
+
+
+ Change Password
+
+ Enter your current password and choose a new one. The new password must meet this proxy's password
+ policy.
+
+
+
+
+
+
+ );
+}
+
+export default ChangePasswordForm;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/page.tsx
new file mode 100644
index 00000000000..0a6ae926ceb
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import ChangePasswordForm from "./ChangePasswordForm";
+
+export default function ChangePasswordPage() {
+ return ;
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts
index 40d1ec09d1f..089153cec76 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts
@@ -50,6 +50,7 @@ const useAuthorized = () => {
isViewOnly: isViewOnlySessionRole(decoded?.user_role),
premiumUser: decoded?.premium_user ?? null,
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,
+ loginMethod: decoded?.login_method ?? null,
showSSOBanner: decoded?.login_method === "username_password",
};
};
diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx
index cad5ced340e..4bdf0da3407 100644
--- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx
@@ -3,13 +3,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
import UserDropdown from "./UserDropdown";
-let mockUseAuthorizedImpl = () => ({
+let mockUseAuthorizedImpl: () => {
+ userId: string | null;
+ userEmail: string | null;
+ userRoleLabel: string;
+ premiumUser: boolean;
+ loginMethod?: string | null;
+} = () => ({
userId: "test-user-id",
userEmail: "test@example.com",
userRoleLabel: "Admin",
premiumUser: false,
});
+const mockRouterPush = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockRouterPush }),
+}));
+
let mockUseDisableShowPromptsImpl = () => false;
let mockGetLocalStorageItemImpl = (key: string): string | null => {
@@ -143,6 +155,44 @@ describe("UserDropdown", () => {
expect(mockOnLogout).toHaveBeenCalledTimes(1);
});
+ it("should navigate to the change-password page for username/password sessions", async () => {
+ mockUseAuthorizedImpl = () => ({
+ userId: "test-user-id",
+ userEmail: "test@example.com",
+ userRoleLabel: "Admin",
+ premiumUser: false,
+ loginMethod: "username_password",
+ });
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(getAccountTrigger());
+
+ await user.click(await screen.findByText("Change Password"));
+
+ expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining("change-password"));
+ });
+
+ it("should hide the change-password entry for SSO sessions", async () => {
+ mockUseAuthorizedImpl = () => ({
+ userId: "test-user-id",
+ userEmail: "test@example.com",
+ userRoleLabel: "Admin",
+ premiumUser: false,
+ loginMethod: "sso",
+ });
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await user.click(getAccountTrigger());
+
+ await waitFor(() => {
+ expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
+ });
+
+ expect(screen.queryByText("Change Password")).not.toBeInTheDocument();
+ });
+
it("should toggle hide new feature indicators switch", async () => {
const user = userEvent.setup();
renderWithProviders( );
diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx
index 95f76dbb2cc..9c02defc778 100644
--- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx
@@ -9,7 +9,9 @@ import {
setLocalStorageItem,
} from "@/utils/localStorageUtils";
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
-import { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react";
+import { uiHref } from "@/utils/uiHref";
+import { ChevronDown, ChevronsUpDown, Crown, KeyRound, LogOut, Mail, ShieldCheck, User } from "lucide-react";
+import { useRouter } from "next/navigation";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -63,7 +65,9 @@ interface UserDropdownProps {
}
const UserDropdown: React.FC = ({ onLogout, variant = "navbar", collapsed = false }) => {
- const { userId, userEmail, userRoleLabel: userRole, premiumUser } = useAuthorized();
+ const { userId, userEmail, userRoleLabel: userRole, premiumUser, loginMethod } = useAuthorized();
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
const disableShowPrompts = useDisableShowPrompts();
const disableBlogPosts = useDisableBlogPosts();
const disableBouncingIcon = useDisableBouncingIcon();
@@ -197,7 +201,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
const displayName = navAccountDisplayName(userEmail, userId);
return (
-
+
{variant === "sidebar" ? (
= ({ onLogout, variant = "navbar
>
{renderUserInfoSection()}
+ {loginMethod === "username_password" && (
+ {
+ setOpen(false);
+ router.push(uiHref("change-password"));
+ }}
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
+ >
+
+ Change Password
+
+ )}
AuthMock = () => ({
@@ -19,6 +20,12 @@ let mockUseAuthorizedImpl: () => AuthMock = () => ({
accessToken: "test-token",
});
+const mockRouterPush = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: mockRouterPush }),
+}));
+
let mockUseDisableShowPromptsImpl = () => false;
let mockUseDisableBouncingIconImpl = () => false;
let mockHealthDataImpl = (): { litellm_version?: string } | undefined => ({ litellm_version: "1.99.0" });
@@ -201,6 +208,42 @@ describe("SidebarAccountMenu", () => {
expect(mockOnLogout).toHaveBeenCalledTimes(1);
});
+ it("should navigate to the change-password page for username/password sessions", async () => {
+ mockUseAuthorizedImpl = () => ({
+ userId: "test-user-id",
+ userEmail: "test@example.com",
+ userRoleLabel: "Admin",
+ premiumUser: false,
+ accessToken: "test-token",
+ loginMethod: "username_password",
+ });
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await openMenu(user);
+
+ await user.click(screen.getByRole("button", { name: /change password/i }));
+
+ expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining("change-password"));
+ });
+
+ it("should hide the change-password entry for SSO sessions", async () => {
+ mockUseAuthorizedImpl = () => ({
+ userId: "test-user-id",
+ userEmail: "test@example.com",
+ userRoleLabel: "Admin",
+ premiumUser: false,
+ accessToken: "test-token",
+ loginMethod: "sso",
+ });
+ const user = userEvent.setup();
+ renderWithProviders( );
+
+ await openMenu(user);
+
+ expect(screen.queryByRole("button", { name: /change password/i })).not.toBeInTheDocument();
+ });
+
it("should toggle hide new feature indicators on", async () => {
const user = userEvent.setup();
renderWithProviders( );
diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx
index ea0b82869cd..e697cc6f34a 100644
--- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx
+++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx
@@ -14,7 +14,9 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/cva.config";
-import { ChevronsUpDown, Crown, IdCard, LogOut, Mail, ShieldCheck } from "lucide-react";
+import { uiHref } from "@/utils/uiHref";
+import { ChevronsUpDown, Crown, IdCard, KeyRound, LogOut, Mail, ShieldCheck } from "lucide-react";
+import { useRouter } from "next/navigation";
import React from "react";
const RELEASE_NOTES_URL = "https://docs.litellm.ai/release_notes";
@@ -81,7 +83,9 @@ interface SidebarAccountMenuProps {
}
const SidebarAccountMenu: React.FC = ({ onLogout, collapsed = false }) => {
- const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken } = useAuthorized();
+ const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken, loginMethod } = useAuthorized();
+ const router = useRouter();
+ const [open, setOpen] = React.useState(false);
const { data: healthData } = useHealthReadinessDetails(accessToken);
const version = healthData?.litellm_version;
const disableShowPrompts = useDisableShowPrompts();
@@ -136,7 +140,7 @@ const SidebarAccountMenu: React.FC = ({ onLogout, colla
const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`;
return (
-
+
= ({ onLogout, colla
+ {loginMethod === "username_password" && (
+ {
+ setOpen(false);
+ router.push(uiHref("change-password"));
+ }}
+ className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground"
+ >
+
+ Change Password
+
+ )}
+
=> {
+ return await apiClient.post(`/user/password/change`, {
+ accessToken,
+ body: {
+ current_password: currentPassword,
+ new_password: newPassword,
+ },
+ });
+};
+
export const regenerateKeyCall = async (accessToken: string, keyToRegenerate: string, formData: any) => {
try {
const url = proxyBaseUrl
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index c5ee810069d..230f471653b 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -16821,6 +16821,35 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/user/password/change": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Change Password
+ * @description Change the calling user's own password.
+ *
+ * Requires the current password. The new password must satisfy the
+ * configured password policy (`general_settings.password_policy_*`: minimum
+ * length, character classes, and, when enabled, breached-password screening
+ * via haveibeenpwned.com).
+ *
+ * Parameters:
+ * - current_password: str - The user's current password.
+ * - new_password: str - The password to change to.
+ */
+ post: operations["change_password_user_password_change_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/user/spend/report": {
parameters: {
query?: never;
@@ -16868,7 +16897,7 @@ export interface paths {
* Parameters:
* - user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
* - user_email: Optional[str] - Specify a user email.
- * - password: Optional[str] - Specify a user password.
+ * - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change.
* - user_alias: Optional[str] - A descriptive name for you to know who this user id refers to.
* - teams: Optional[list] - specify a list of team id's a user belongs to.
* - send_invite_email: Optional[bool] - Specify if an invite email should be sent.
@@ -24906,6 +24935,20 @@ export interface components {
*/
status: "cancelled";
};
+ /** ChangePasswordRequest */
+ ChangePasswordRequest: {
+ /** Current Password */
+ current_password: string;
+ /** New Password */
+ new_password: string;
+ };
+ /** ChangePasswordResponse */
+ ChangePasswordResponse: {
+ /** Message */
+ message: string;
+ /** User Id */
+ user_id: string;
+ };
/** ChatCompletionAnnotation */
ChatCompletionAnnotation: {
/**
@@ -60955,6 +60998,39 @@ export interface operations {
};
};
};
+ change_password_user_password_change_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ChangePasswordRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ChangePasswordResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_user_spend_report_user_spend_report_get: {
parameters: {
query?: {
From faf755345abe1ba9ae991d9fdb7e3546eaec5973 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Wed, 9 Sep 2026 12:17:43 +0200
Subject: [PATCH 10/16] fix(auth): clear the CI gates on the change-password PR
The Terraform endpoint audit wanted POST /user/password/change covered
or allowlisted; it is a caller-scoped one-shot action, so allowlist it
next to /user/bulk_update. leftnav.test.tsx mocked next/navigation
without useRouter, which SidebarAccountMenu now calls, so every render
in that file threw. The two unannotated audit-log patches in
test_password_endpoints.py get their test-quality-ok reasons.
Also removes the LIT002 violations the PR added: prisma input TypedDicts
annotate the where/data dicts, a shared HTTPExceptionErrorDetail
TypedDict covers the HTTPException detail dicts, and the route decorator
takes a tags tuple.
---
litellm/proxy/_types.py | 6 ++
.../internal_user_endpoints.py | 16 ++--
.../password_endpoints.py | 34 +++----
.../endpointaudit/coverage_allowlist.txt | 1 +
.../test_password_endpoints.py | 88 ++++++++++++++-----
.../src/components/leftnav.test.tsx | 1 +
6 files changed, 100 insertions(+), 46 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 7731ca736d5..fc05796db5a 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -3814,6 +3814,12 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
)
+class HTTPExceptionErrorDetail(TypedDict):
+ """The `{"error": }` shape most proxy endpoints raise as `HTTPException.detail`."""
+
+ error: ReadOnly[str]
+
+
class SpendLogsRouterMetadata(TypedDict):
"""
Router provenance stamped on spend logs for deployments flagged with
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 368d5ed0c10..addc68791b4 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -1882,15 +1882,13 @@ async def bulk_user_update(
detail="Only proxy admins can update all users at once.",
)
if data.user_updates.password is not None:
- raise HTTPException(
- status_code=400,
- detail={
- "error": (
- "Setting one password for all users is not supported. "
- "Use per-user updates via the 'users' list instead."
- )
- },
- )
+ bulk_password_error: Final[HTTPExceptionErrorDetail] = {
+ "error": (
+ "Setting one password for all users is not supported. "
+ "Use per-user updates via the 'users' list instead."
+ )
+ }
+ raise HTTPException(status_code=400, detail=bulk_password_error)
# Optimized path for updating all users directly in database
all_users_in_db: Final = await _user_table(prisma_client).find_many(order={"created_at": "desc"})
diff --git a/litellm/proxy/management_endpoints/password_endpoints.py b/litellm/proxy/management_endpoints/password_endpoints.py
index 78e53d7a777..c1d409cc08d 100644
--- a/litellm/proxy/management_endpoints/password_endpoints.py
+++ b/litellm/proxy/management_endpoints/password_endpoints.py
@@ -17,6 +17,7 @@ from litellm.proxy._types import (
ChangePasswordRequest,
ChangePasswordResponse,
CommonProxyErrors,
+ HTTPExceptionErrorDetail,
LitellmTableNames,
UserAPIKeyAuth,
)
@@ -29,6 +30,7 @@ from litellm.repositories.user_repository import UserRepository
if TYPE_CHECKING:
from prisma import models as prisma_models
+ from prisma import types as prisma_types
from litellm.proxy.utils import PrismaClient
@@ -37,6 +39,11 @@ router: Final = APIRouter()
_PASSWORD_CHANGED_AUDIT_VALUES: Final = '{"fields_changed": ["password"]}'
+def _error_detail(message: str) -> HTTPExceptionErrorDetail:
+ detail: Final[HTTPExceptionErrorDetail] = {"error": message}
+ return detail
+
+
def _user_table(
prisma_client: "PrismaClient | None",
) -> "TableActions[prisma_models.LiteLLM_UserTable]":
@@ -46,7 +53,7 @@ def _user_table(
@router.post(
"/user/password/change",
- tags=["Internal User management"],
+ tags=("Internal User management",),
dependencies=(Depends(user_api_key_auth),),
)
async def change_password(
@@ -70,39 +77,36 @@ async def change_password(
if prisma_client is None:
raise HTTPException(
status_code=500,
- detail={"error": CommonProxyErrors.db_not_connected_error.value},
+ detail=_error_detail(CommonProxyErrors.db_not_connected_error.value),
)
user_id: Final = user_api_key_dict.user_id
if user_id is None:
raise HTTPException(
status_code=400,
- detail={"error": "No user is associated with this session, so there is no password to change."},
+ detail=_error_detail("No user is associated with this session, so there is no password to change."),
)
- user_row: Final = await _user_table(prisma_client).find_first(where={"user_id": user_id})
+ find_user: Final[prisma_types.LiteLLM_UserTableWhereInput] = {"user_id": user_id}
+ user_row: Final = await _user_table(prisma_client).find_first(where=find_user)
stored_password: Final = user_row.password if user_row is not None else None
if stored_password is None:
raise HTTPException(
status_code=400,
- detail={
- "error": (
- "This account has no password set, so there is no password to change. "
- "Passwords are set through an invitation link (POST /invitation/new)."
- )
- },
+ detail=_error_detail(
+ "This account has no password set, so there is no password to change. "
+ "Passwords are set through an invitation link (POST /invitation/new)."
+ ),
)
if not verify_password(data.current_password, stored_password):
- raise HTTPException(status_code=400, detail={"error": "Current password is incorrect."})
+ raise HTTPException(status_code=400, detail=_error_detail("Current password is incorrect."))
validate_password_policy(data.new_password, general_settings)
await validate_password_not_breached(data.new_password, general_settings)
- await _user_table(prisma_client).update(
- where={"user_id": user_id},
- data={"password": hash_password(data.new_password)},
- )
+ password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"password": hash_password(data.new_password)}
+ await _user_table(prisma_client).update(where=find_user, data=password_update)
verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id)
await create_object_audit_log(
diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt
index 6bc8947e89f..73bf2eac2b7 100644
--- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt
+++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt
@@ -85,6 +85,7 @@ POST /team/key/bulk_update
POST /team/permissions_bulk_update
POST /team/{team_id}/disable_logging
POST /user/bulk_update
+POST /user/password/change
# Alternate method or path for functionality the provider already manages elsewhere
GET /credentials/by_model/{model_id}
diff --git a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
index c7e0a385ae9..4c6de608003 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
@@ -56,8 +56,12 @@ async def test_change_password_success_writes_new_scrypt_hash():
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
):
response = await change_password(
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
@@ -79,8 +83,12 @@ async def test_change_password_rejects_wrong_current_password():
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
):
with pytest.raises(HTTPException) as exc_info:
await change_password(
@@ -100,8 +108,12 @@ async def test_change_password_rejects_session_without_user():
prisma = _make_prisma(user=None)
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
):
with pytest.raises(HTTPException) as exc_info:
await change_password(
@@ -122,8 +134,12 @@ async def test_change_password_rejects_account_without_password():
prisma = _make_prisma(_make_user_row(password=None))
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
):
with pytest.raises(HTTPException) as exc_info:
await change_password(
@@ -143,8 +159,12 @@ async def test_change_password_enforces_min_length():
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
):
with pytest.raises(ProxyException) as exc_info:
await change_password(
@@ -172,8 +192,12 @@ async def test_change_password_rejects_breached_password():
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", {}
+ ),
):
with pytest.raises(ProxyException) as exc_info:
await change_password(
@@ -201,8 +225,12 @@ async def test_change_password_verifies_current_password_before_hibp_lookup():
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", {}
+ ),
):
with pytest.raises(HTTPException) as exc_info:
await change_password(
@@ -226,9 +254,15 @@ async def test_change_password_success_emits_redacted_audit_log():
audit_mock = AsyncMock()
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
+ patch( # test-quality-ok: audit sink is a module-level import; no injection seam
+ "litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock
+ ),
):
await change_password(
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
@@ -253,9 +287,15 @@ async def test_change_password_failure_emits_no_audit_log():
audit_mock = AsyncMock()
with (
- patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", prisma
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
+ patch( # test-quality-ok: audit sink is a module-level import; no injection seam
+ "litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock
+ ),
):
with pytest.raises(HTTPException):
await change_password(
@@ -271,8 +311,12 @@ async def test_change_password_requires_db():
from litellm.proxy._types import ChangePasswordRequest
with (
- patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
- patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.prisma_client", None
+ ),
+ patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
+ "litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
+ ),
):
with pytest.raises(HTTPException) as exc_info:
await change_password(
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index 6eb0218c41d..5fa6e9728cf 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -21,6 +21,7 @@ const navState = vi.hoisted(() => ({ pathname: "/ui/api-keys" }));
vi.mock("next/navigation", () => ({
usePathname: () => navState.pathname,
+ useRouter: () => ({ push: vi.fn() }),
}));
const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
From 671d032b20380d30fa5bf60ad4cd3904c040b556 Mon Sep 17 00:00:00 2001
From: Oliver Jensen
Date: Mon, 7 Sep 2026 12:47:39 +0200
Subject: [PATCH 11/16] feat(auth): force password reset for breached or
admin-set passwords
---
.../migration.sql | 3 +
.../litellm_proxy_extras/schema.prisma | 2 +
litellm/models/user.py | 2 +
litellm/proxy/auth/login_utils.py | 70 +++++
litellm/proxy/auth/route_checks.py | 10 +
.../internal_user_endpoints.py | 9 +-
.../password_endpoints.py | 9 +-
litellm/proxy/management_endpoints/ui_sso.py | 1 +
litellm/proxy/proxy_server.py | 9 +-
litellm/proxy/schema.prisma | 2 +
litellm/types/proxy/ui_sso.py | 3 +-
schema.prisma | 2 +
.../proxy/auth/test_login_utils.py | 243 ++++++++++++++++++
.../proxy/auth/test_onboarding.py | 4 +
.../proxy/auth/test_route_checks.py | 61 +++++
.../test_internal_user_endpoints.py | 4 +
.../test_password_endpoints.py | 4 +
.../ChangePasswordForm.integration.test.tsx | 46 +++-
.../change-password/ChangePasswordForm.tsx | 23 +-
.../app/(dashboard)/hooks/useAuthorized.ts | 1 +
.../src/app/(dashboard)/layout.test.tsx | 57 +++-
.../src/app/(dashboard)/layout.tsx | 11 +-
.../src/contexts/AuthContext.tsx | 4 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 13 +-
24 files changed, 579 insertions(+), 14 deletions(-)
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql
new file mode 100644
index 00000000000..960b0d4d7eb
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_add_password_reset_columns/migration.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "LiteLLM_UserTable" ADD COLUMN IF NOT EXISTS "password_reset_required" BOOLEAN;
+
+ALTER TABLE "LiteLLM_UserTable" ADD COLUMN IF NOT EXISTS "last_breach_check_at" TIMESTAMP(3);
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index dd7967aafe3..3b6a2de4632 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -241,6 +241,8 @@ model LiteLLM_UserTable {
organization_id String?
object_permission_id String?
password String?
+ password_reset_required Boolean?
+ last_breach_check_at DateTime?
teams String[] @default([])
user_role String?
max_budget Float?
diff --git a/litellm/models/user.py b/litellm/models/user.py
index 82f78c28078..92aca87d303 100644
--- a/litellm/models/user.py
+++ b/litellm/models/user.py
@@ -24,6 +24,8 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase):
organization_id: str | None = None
object_permission_id: str | None = None
password: str | None = Field(default=None, exclude=True)
+ password_reset_required: bool | None = None
+ last_breach_check_at: datetime | None = None
teams: list[str] = []
user_role: str | None = None
max_budget: float | None = None
diff --git a/litellm/proxy/auth/login_utils.py b/litellm/proxy/auth/login_utils.py
index c0a76a4fc20..a5809ce0899 100644
--- a/litellm/proxy/auth/login_utils.py
+++ b/litellm/proxy/auth/login_utils.py
@@ -5,6 +5,7 @@ This module contains the core login logic that can be reused across different
login endpoints (e.g., /login and /v2/login).
"""
+import asyncio
import os
import secrets
from collections.abc import Mapping
@@ -16,8 +17,10 @@ import jwt
from fastapi import HTTPException
import litellm
+from litellm._logging import verbose_proxy_logger
from litellm.constants import LITELLM_PROXY_ADMIN_NAME, LITELLM_UI_SESSION_DURATION
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTable,
LitellmUserRoles,
@@ -27,6 +30,7 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
+from litellm.proxy.auth.password_policy import is_breach_check_enabled, is_password_breached
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
@@ -44,6 +48,48 @@ from litellm.repositories.user_repository import UserRepository
from litellm.secret_managers.main import get_secret_bool
from litellm.types.proxy.ui_sso import ReturnedUITokenObject
+BREACH_RECHECK_INTERVAL: Final = timedelta(hours=24)
+PASSWORD_RESET_ALLOWED_ROUTES: Final = ("/user/password/change",)
+
+
+def _breach_recheck_due(last_breach_check_at: datetime | None) -> bool:
+ if last_breach_check_at is None:
+ return True
+ last_checked_utc: Final = (
+ last_breach_check_at
+ if last_breach_check_at.tzinfo is not None
+ else last_breach_check_at.replace(tzinfo=timezone.utc)
+ )
+ return datetime.now(timezone.utc) - last_checked_utc >= BREACH_RECHECK_INTERVAL
+
+
+async def screen_login_password_for_breach(
+ user_id: str,
+ password: str,
+ last_breach_check_at: datetime | None,
+ general_settings: Mapping[str, object],
+ prisma_client: PrismaClient,
+ client: AsyncHTTPHandler | None = None,
+) -> None:
+ """Background task behind a successful password login: screens the password
+ against HIBP and stamps ``password_reset_required`` when breached, so the
+ NEXT login is restricted to the change-password flow. Never blocks or fails
+ the login it runs behind, and rechecks a given user at most once per
+ ``BREACH_RECHECK_INTERVAL``."""
+ if not is_breach_check_enabled(general_settings):
+ return
+ if not _breach_recheck_due(last_breach_check_at):
+ return
+ breached: Final = await is_password_breached(password, general_settings, client)
+ update_data: Final = {
+ "last_breach_check_at": datetime.now(timezone.utc),
+ **({"password_reset_required": True} if breached else {}),
+ }
+ try:
+ await UserRepository(prisma_client).table.update(where={"user_id": user_id}, data=update_data)
+ except Exception as e: # noqa: BLE001 # fire-and-forget: a failed stamp must never surface into the login
+ verbose_proxy_logger.warning("Login-time breach screening could not update user %s: %s", user_id, e)
+
async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None:
"""Rehash legacy password (SHA256) to scrypt on successful login."""
@@ -116,6 +162,7 @@ class LoginResult:
user_email: str | None
user_role: str
login_method: Literal["sso", "username_password"]
+ password_reset_required: bool
def __init__(
self,
@@ -124,12 +171,14 @@ class LoginResult:
user_email: str | None,
user_role: str,
login_method: Literal["sso", "username_password"] = "username_password",
+ password_reset_required: bool = False,
):
self.user_id = user_id
self.key = key
self.user_email = user_email
self.user_role = user_role
self.login_method = login_method
+ self.password_reset_required = password_reset_required
async def authenticate_user(
@@ -322,6 +371,17 @@ async def authenticate_user(
if verify_password(password, _password):
await _rehash_password_if_needed(_user_row.user_id, password, _password)
+ if prisma_client is not None:
+ asyncio.create_task(
+ screen_login_password_for_breach(
+ user_id=_user_row.user_id,
+ password=password,
+ last_breach_check_at=getattr(_user_row, "last_breach_check_at", None),
+ general_settings=general_settings,
+ prisma_client=prisma_client,
+ )
+ )
+ password_reset_required: Final = getattr(_user_row, "password_reset_required", None) is True
if os.getenv("DATABASE_URL") is not None:
response = await generate_key_helper_fn(
request_type="key",
@@ -335,6 +395,14 @@ async def authenticate_user(
"spend": 0,
"user_id": user_id,
"team_id": "litellm-dashboard",
+ **(
+ {
+ "allowed_routes": list(PASSWORD_RESET_ALLOWED_ROUTES),
+ "metadata": {"password_reset_required": True},
+ }
+ if password_reset_required
+ else {}
+ ),
},
)
else:
@@ -353,6 +421,7 @@ async def authenticate_user(
user_email=user_email,
user_role=cast(str, user_role),
login_method="username_password",
+ password_reset_required=password_reset_required,
)
else:
raise ProxyException(
@@ -426,4 +495,5 @@ def create_ui_token_object(
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=login_result.password_reset_required,
)
diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py
index e4a36e73373..c4109bdc2f1 100644
--- a/litellm/proxy/auth/route_checks.py
+++ b/litellm/proxy/auth/route_checks.py
@@ -187,6 +187,16 @@ class RouteChecks:
if denied_auth_enforced_pass_through_route:
raise RouteChecks._auth_pass_through_denied_exception(route=route)
+ if valid_token.metadata.get("password_reset_required") is True:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=(
+ "This account's password must be changed before the session can be used: "
+ "it was either found in a known data breach or set by an admin. "
+ "Change it via POST /user/password/change (UI: /ui/change-password), then log in again."
+ ),
+ )
+
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Virtual key is not allowed to call this route. Only allowed to call routes: {valid_token.allowed_routes}. Tried to call route: {route}",
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index addc68791b4..202fe97889f 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -173,12 +173,17 @@ async def _hash_password_in_dict(
"""Validate and hash password field in-place if present.
``password_prevalidated`` skips the policy checks for callers that already
- validated the password (the bulk path screens its whole batch upfront)."""
+ validated the password (the bulk path screens its whole batch upfront).
+
+ An admin-set password is known to whoever set it, so the user is also
+ flagged for a forced password change at next login."""
if "password" in data and data["password"] is not None:
if not password_prevalidated:
validate_password_policy(data["password"], general_settings)
await validate_password_not_breached(data["password"], general_settings)
data["password"] = hash_password(data["password"])
+ data["password_reset_required"] = True
+ data["last_breach_check_at"] = None
def _strip_password_from_response(response) -> None:
@@ -1644,7 +1649,7 @@ async def user_update(
Parameters:
- user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
- user_email: Optional[str] - Specify a user email.
- - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change.
+ - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. The user is required to change it at their next login. Users change their own password with POST /user/password/change.
- user_alias: Optional[str] - A descriptive name for you to know who this user id refers to.
- teams: Optional[list] - specify a list of team id's a user belongs to.
- send_invite_email: Optional[bool] - Specify if an invite email should be sent.
diff --git a/litellm/proxy/management_endpoints/password_endpoints.py b/litellm/proxy/management_endpoints/password_endpoints.py
index c1d409cc08d..bd3c9722d4d 100644
--- a/litellm/proxy/management_endpoints/password_endpoints.py
+++ b/litellm/proxy/management_endpoints/password_endpoints.py
@@ -66,7 +66,8 @@ async def change_password(
Requires the current password. The new password must satisfy the
configured password policy (`general_settings.password_policy_*`: minimum
length, character classes, and, when enabled, breached-password screening
- via haveibeenpwned.com).
+ via haveibeenpwned.com). A successful change lifts any pending forced
+ password reset (`password_reset_required`) on the account.
Parameters:
- current_password: str - The user's current password.
@@ -105,7 +106,11 @@ async def change_password(
validate_password_policy(data.new_password, general_settings)
await validate_password_not_breached(data.new_password, general_settings)
- password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"password": hash_password(data.new_password)}
+ password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {
+ "password": hash_password(data.new_password),
+ "password_reset_required": False,
+ "last_breach_check_at": None,
+ }
await _user_table(prisma_client).update(where=find_user, data=password_update)
verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id)
diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py
index 1ba90725eff..5279ebcfcc8 100644
--- a/litellm/proxy/management_endpoints/ui_sso.py
+++ b/litellm/proxy/management_endpoints/ui_sso.py
@@ -3666,6 +3666,7 @@ class SSOAuthenticationHandler:
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=False,
)
from litellm.proxy.auth.login_utils import encode_ui_session_jwt
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index d803e8fd4be..cdf41ed55bb 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -16193,6 +16193,7 @@ async def onboarding(invite_link: str, request: Request):
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=False,
)
jwt_token: Final = jwt.encode(
cast(dict, returned_ui_token_object),
@@ -16302,6 +16303,7 @@ async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str:
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
disabled_non_admin_personal_key_creation=disabled_non_admin_personal_key_creation,
server_root_path=get_server_root_path(),
+ password_reset_required=False,
)
assert master_key is not None
return jwt.encode(
@@ -16392,7 +16394,12 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
### UPDATE USER OBJECT ###
user_obj: Final[_UserTableRow | None] = await tx.litellm_usertable.update(
- where={"user_id": invite_obj.user_id}, data={"password": hashed_pw}
+ where={"user_id": invite_obj.user_id},
+ data={
+ "password": hashed_pw,
+ "password_reset_required": False,
+ "last_breach_check_at": None,
+ },
)
if user_obj is None:
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index dd7967aafe3..3b6a2de4632 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -241,6 +241,8 @@ model LiteLLM_UserTable {
organization_id String?
object_permission_id String?
password String?
+ password_reset_required Boolean?
+ last_breach_check_at DateTime?
teams String[] @default([])
user_role String?
max_budget Float?
diff --git a/litellm/types/proxy/ui_sso.py b/litellm/types/proxy/ui_sso.py
index 0d7e0b99cf0..03b0b92a4d1 100644
--- a/litellm/types/proxy/ui_sso.py
+++ b/litellm/types/proxy/ui_sso.py
@@ -1,6 +1,6 @@
from typing import Literal
-from typing_extensions import TypedDict
+from typing_extensions import ReadOnly, TypedDict
class ReturnedUITokenObject(TypedDict):
@@ -17,6 +17,7 @@ class ReturnedUITokenObject(TypedDict):
auth_header_name: str
disabled_non_admin_personal_key_creation: bool
server_root_path: str # e.g. `/litellm`
+ password_reset_required: ReadOnly[bool]
class ParsedOpenIDResult(TypedDict, total=False):
diff --git a/schema.prisma b/schema.prisma
index dd7967aafe3..3b6a2de4632 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -241,6 +241,8 @@ model LiteLLM_UserTable {
organization_id String?
object_permission_id String?
password String?
+ password_reset_required Boolean?
+ last_breach_check_at DateTime?
teams String[] @default([])
user_role String?
max_budget Float?
diff --git a/tests/test_litellm/proxy/auth/test_login_utils.py b/tests/test_litellm/proxy/auth/test_login_utils.py
index e209a491b0a..16547db527c 100644
--- a/tests/test_litellm/proxy/auth/test_login_utils.py
+++ b/tests/test_litellm/proxy/auth/test_login_utils.py
@@ -5,13 +5,17 @@ This module tests the refactored login logic that was moved from proxy_server.py
to login_utils.py for better reusability.
"""
+import hashlib
import os
from contextlib import ExitStack
+from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
import pytest
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTable,
LitellmUserRoles,
@@ -24,8 +28,13 @@ from litellm.proxy.auth.login_utils import (
authenticate_user,
get_ui_credentials,
is_env_credential_login_enabled,
+ screen_login_password_for_breach,
)
+# Successful DB-user logins schedule the background HIBP screen; disable it so
+# no test ever does live network I/O to haveibeenpwned.com from CI.
+_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
+
def test_get_ui_credentials_prefers_explicit_password():
"""The configured UI password should be returned when available."""
@@ -298,12 +307,14 @@ async def test_authenticate_user_email_case_insensitive_login():
password=correct_password,
master_key=master_key,
prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
)
result_lower = await authenticate_user(
username=stored_email,
password=correct_password,
master_key=master_key,
prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
)
assert result_mixed.user_id == result_lower.user_id == "test-user-123"
@@ -541,6 +552,7 @@ async def test_authenticate_user_database_login_with_non_ascii_password():
password=password_with_special_char,
master_key=master_key,
prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
)
assert isinstance(result, LoginResult)
@@ -956,3 +968,234 @@ class TestIsEnvCredentialLoginEnabled:
with ExitStack() as stack:
_patch_sso_configured(stack, configured=False)
assert is_env_credential_login_enabled({"disable_password_login_when_sso_enabled": True}) is True
+
+
+def _db_user_row(*, password: str, password_reset_required: bool | None = None, last_breach_check_at=None):
+ hashed = hash_token(token=password)
+ row = MagicMock()
+ row.user_id = "reset-user-1"
+ row.user_email = "reset@example.com"
+ row.password = hashed
+ row.user_role = LitellmUserRoles.INTERNAL_USER
+ row.password_reset_required = password_reset_required
+ row.last_breach_check_at = last_breach_check_at
+ return row
+
+
+def _prisma_with_user(row) -> MagicMock:
+ mock_prisma_client = MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=row)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=row)
+ return mock_prisma_client
+
+
+_DB_LOGIN_ENV = {
+ "DATABASE_URL": "postgresql://test:test@localhost/test",
+ "UI_USERNAME": "admin",
+ "UI_PASSWORD": "admin-password",
+}
+
+
+class TestPasswordResetRequiredSessionMinting:
+ """A user flagged `password_reset_required` must receive a UI session key
+ restricted to the change-password endpoint (server-side enforcement, so a
+ script driving the management API with the session key is blocked too);
+ an unflagged user must keep getting an unrestricted key."""
+
+ async def _login(self, mock_prisma_client) -> tuple[LoginResult, dict]:
+ with patch.dict(os.environ, _DB_LOGIN_ENV):
+ with patch(
+ "litellm.proxy.auth.login_utils.generate_key_helper_fn",
+ new_callable=AsyncMock,
+ return_value={"token": "session-token"},
+ ) as mock_generate_key:
+ result = await authenticate_user(
+ username="reset@example.com",
+ password="Str0ng!Passw0rd",
+ master_key="sk-1234",
+ prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
+ )
+ return result, mock_generate_key.call_args.kwargs
+
+ @pytest.mark.asyncio
+ async def test_flagged_user_gets_key_restricted_to_change_password(self):
+ row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=True)
+ result, key_kwargs = await self._login(_prisma_with_user(row))
+
+ assert key_kwargs["allowed_routes"] == ["/user/password/change"]
+ assert key_kwargs["metadata"] == {"password_reset_required": True}
+ assert result.password_reset_required is True
+
+ @pytest.mark.asyncio
+ async def test_unflagged_user_gets_unrestricted_key(self):
+ row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None)
+ result, key_kwargs = await self._login(_prisma_with_user(row))
+
+ assert "allowed_routes" not in key_kwargs
+ assert "metadata" not in key_kwargs
+ assert result.password_reset_required is False
+
+ @pytest.mark.asyncio
+ async def test_login_schedules_breach_screen_with_row_state(self):
+ """The login must hand the background screen the row's recheck timestamp,
+ or the 24h throttle can never work."""
+ checked_at = datetime.now(timezone.utc) - timedelta(hours=1)
+ row = _db_user_row(password="Str0ng!Passw0rd", last_breach_check_at=checked_at)
+ mock_prisma_client = _prisma_with_user(row)
+
+ with patch.dict(os.environ, _DB_LOGIN_ENV):
+ with patch(
+ "litellm.proxy.auth.login_utils.generate_key_helper_fn",
+ new_callable=AsyncMock,
+ return_value={"token": "session-token"},
+ ):
+ with patch(
+ "litellm.proxy.auth.login_utils.screen_login_password_for_breach",
+ new_callable=AsyncMock,
+ ) as mock_screen:
+ await authenticate_user(
+ username="reset@example.com",
+ password="Str0ng!Passw0rd",
+ master_key="sk-1234",
+ prisma_client=mock_prisma_client,
+ general_settings=_POLICY_NO_BREACH_CHECK,
+ )
+
+ screen_kwargs = mock_screen.call_args.kwargs
+ assert screen_kwargs["user_id"] == "reset-user-1"
+ assert screen_kwargs["password"] == "Str0ng!Passw0rd"
+ assert screen_kwargs["last_breach_check_at"] == checked_at
+ assert screen_kwargs["prisma_client"] is mock_prisma_client
+
+
+def _sha1_upper(password: str) -> str:
+ return hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
+
+
+def _client_with_transport(handler) -> AsyncHTTPHandler:
+ http_handler = AsyncHTTPHandler()
+ http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+ return http_handler
+
+
+def _client_returning_breach_hit(password: str) -> AsyncHTTPHandler:
+ body = f"{_sha1_upper(password)[5:]}:42"
+ return _client_with_transport(lambda request: httpx.Response(200, text=body))
+
+
+def _client_returning_no_hit() -> AsyncHTTPHandler:
+ return _client_with_transport(lambda request: httpx.Response(200, text="0000000000000000000000000000000000A:3"))
+
+
+def _client_never_called() -> AsyncHTTPHandler:
+ def handler(request: httpx.Request) -> httpx.Response:
+ raise AssertionError(f"unexpected HTTP call to {request.url}")
+
+ return _client_with_transport(handler)
+
+
+class TestScreenLoginPasswordForBreach:
+ """The fire-and-forget login-time screen: flags a breached password for a
+ forced reset, stamps the recheck timestamp, rechecks at most every 24h,
+ and never raises into the login it runs behind."""
+
+ @pytest.mark.asyncio
+ async def test_breached_password_sets_reset_flag_and_timestamp(self):
+ password = "Password123!"
+ mock_prisma_client = _prisma_with_user(None)
+
+ await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password=password,
+ last_breach_check_at=None,
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_breach_hit(password),
+ )
+
+ update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
+ assert update_kwargs["where"] == {"user_id": "reset-user-1"}
+ assert update_kwargs["data"]["password_reset_required"] is True
+ assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
+
+ @pytest.mark.asyncio
+ async def test_clean_password_stamps_timestamp_without_flag(self):
+ mock_prisma_client = _prisma_with_user(None)
+
+ await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password="Str0ng!Passw0rd",
+ last_breach_check_at=None,
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_no_hit(),
+ )
+
+ update_kwargs = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs
+ assert "password_reset_required" not in update_kwargs["data"]
+ assert isinstance(update_kwargs["data"]["last_breach_check_at"], datetime)
+
+ @pytest.mark.asyncio
+ async def test_skips_hibp_when_checked_within_24_hours(self):
+ mock_prisma_client = _prisma_with_user(None)
+
+ await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password="Password123!",
+ last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=23),
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_never_called(),
+ )
+
+ mock_prisma_client.db.litellm_usertable.update.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_rechecks_when_last_check_is_older_than_24_hours(self):
+ password = "Password123!"
+ mock_prisma_client = _prisma_with_user(None)
+
+ await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password=password,
+ last_breach_check_at=datetime.now(timezone.utc) - timedelta(hours=25),
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_breach_hit(password),
+ )
+
+ assert mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["password_reset_required"] is True
+
+ @pytest.mark.asyncio
+ async def test_skips_hibp_when_check_disabled(self):
+ mock_prisma_client = _prisma_with_user(None)
+
+ await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password="Password123!",
+ last_breach_check_at=None,
+ general_settings=_POLICY_NO_BREACH_CHECK,
+ prisma_client=mock_prisma_client,
+ client=_client_never_called(),
+ )
+
+ mock_prisma_client.db.litellm_usertable.update.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_db_failure_never_raises_into_the_login(self):
+ password = "Password123!"
+ mock_prisma_client = _prisma_with_user(None)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(side_effect=RuntimeError("db down"))
+
+ assert (
+ await screen_login_password_for_breach(
+ user_id="reset-user-1",
+ password=password,
+ last_breach_check_at=None,
+ general_settings={},
+ prisma_client=mock_prisma_client,
+ client=_client_returning_breach_hit(password),
+ )
+ is None
+ )
diff --git a/tests/test_litellm/proxy/auth/test_onboarding.py b/tests/test_litellm/proxy/auth/test_onboarding.py
index 8939baedd50..0454aea1239 100644
--- a/tests/test_litellm/proxy/auth/test_onboarding.py
+++ b/tests/test_litellm/proxy/auth/test_onboarding.py
@@ -463,6 +463,10 @@ async def test_claim_token_sets_accepted_at_after_password_written():
call_kwargs = prisma.db.litellm_usertable.update.call_args
assert call_kwargs.kwargs["where"] == {"user_id": "user-123"}
assert "password" in call_kwargs.kwargs["data"]
+ # A freshly claimed, policy-screened password lifts any pending forced
+ # reset and re-arms the login-time breach screen.
+ assert call_kwargs.kwargs["data"]["password_reset_required"] is False
+ assert call_kwargs.kwargs["data"]["last_breach_check_at"] is None
# is_accepted was flipped to True on the invitation link
prisma.db.litellm_invitationlink.update.assert_called_once()
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index 4f58e3c86ff..83ebc3c9225 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -3717,6 +3717,67 @@ def test_non_admin_roles_can_change_own_password(user_role):
assert allowed is None
+def _password_reset_session_token() -> UserAPIKeyAuth:
+ """The UI session key `authenticate_user` mints for a user flagged
+ `password_reset_required`."""
+ return UserAPIKeyAuth(
+ user_id="flagged_user",
+ allowed_routes=["/user/password/change"],
+ metadata={"password_reset_required": True},
+ )
+
+
+def test_password_reset_session_can_reach_change_password():
+ result = RouteChecks.is_virtual_key_allowed_to_call_route(
+ route="/user/password/change",
+ valid_token=_password_reset_session_token(),
+ )
+
+ assert result is True
+
+
+@pytest.mark.parametrize(
+ "route",
+ [
+ "/user/info",
+ "/key/generate",
+ "/user/update",
+ "/chat/completions",
+ ],
+)
+def test_password_reset_session_is_blocked_everywhere_else_with_reset_message(route):
+ """Server-side enforcement of the forced reset: a script that logs in via
+ /v2/login and drives the management API with the session key must get a 403
+ naming the remediation endpoint, on every route but the change-password one."""
+ with pytest.raises(HTTPException) as exc_info:
+ RouteChecks.is_virtual_key_allowed_to_call_route(
+ route=route,
+ valid_token=_password_reset_session_token(),
+ )
+
+ assert exc_info.value.status_code == 403
+ assert "password must be changed" in str(exc_info.value.detail)
+ assert "/user/password/change" in str(exc_info.value.detail)
+
+
+def test_restricted_key_without_reset_marker_keeps_generic_message():
+ """The reset-specific 403 must not leak onto ordinary allowed_routes keys."""
+ valid_token = UserAPIKeyAuth(
+ user_id="test_user",
+ allowed_routes=["/chat/completions"],
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ RouteChecks.is_virtual_key_allowed_to_call_route(
+ route="/user/info",
+ valid_token=valid_token,
+ )
+
+ assert exc_info.value.status_code == 403
+ assert "password must be changed" not in str(exc_info.value.detail)
+ assert "not allowed to call this route" in str(exc_info.value.detail)
+
+
TEAM_CALLBACK_ROUTES = (
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback",
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback/langfuse",
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index a443235fe15..1892a780cbf 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -4285,6 +4285,10 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo
written_data = mock_prisma_client.update_data.call_args.kwargs["data"]
assert written_data.get("password") is not None
assert written_data["password"] != strong_password
+ # An admin-set password is known to the admin, so the user must be forced
+ # to change it at next login and the breach screen re-armed.
+ assert written_data["password_reset_required"] is True
+ assert written_data["last_breach_check_at"] is None
@pytest.mark.asyncio
diff --git a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
index 4c6de608003..bd154ebab41 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py
@@ -74,6 +74,10 @@ async def test_change_password_success_writes_new_scrypt_hash():
stored = update_kwargs["data"]["password"]
assert stored != NEW_PASSWORD
assert verify_password(NEW_PASSWORD, stored)
+ # A successful change lifts any pending forced reset and re-arms the
+ # login-time breach screen for the new password.
+ assert update_kwargs["data"]["password_reset_required"] is False
+ assert update_kwargs["data"]["last_breach_check_at"] is None
@pytest.mark.asyncio
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
index e78f170cf0c..c4cf8ebcf9d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx
@@ -1,16 +1,19 @@
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChangePasswordForm from "./ChangePasswordForm";
const mockChangePasswordCall = vi.fn();
const mockToastSuccess = vi.fn();
+const mockClearTokenCookies = vi.fn();
+let mockPasswordResetRequired = false;
vi.mock("@/components/networking", () => ({
changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args),
+ getProxyBaseUrl: () => "",
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
- default: () => ({ accessToken: "sk-session-token" }),
+ default: () => ({ accessToken: "sk-session-token", passwordResetRequired: mockPasswordResetRequired }),
}));
vi.mock("@/lib/toast", () => ({
@@ -20,6 +23,10 @@ vi.mock("@/lib/toast", () => ({
},
}));
+vi.mock("@/utils/cookieUtils", () => ({
+ clearTokenCookies: (...args: unknown[]) => mockClearTokenCookies(...args),
+}));
+
const fillForm = (values: { current: string; next: string; confirm: string }) => {
fireEvent.change(screen.getByLabelText("Current Password"), { target: { value: values.current } });
fireEvent.change(screen.getByLabelText("New Password"), { target: { value: values.next } });
@@ -31,6 +38,7 @@ const submit = () => fireEvent.click(screen.getByRole("button", { name: "Change
describe("ChangePasswordForm", () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockPasswordResetRequired = false;
});
it("sends the current and new password to the change endpoint and resets on success", async () => {
@@ -65,4 +73,38 @@ describe("ChangePasswordForm", () => {
expect(await screen.findByText("Current password is incorrect.")).toBeInTheDocument();
expect(mockToastSuccess).not.toHaveBeenCalled();
});
+
+ describe("forced password reset", () => {
+ it("shows the forced-reset warning only when the session is flagged", () => {
+ mockPasswordResetRequired = true;
+ render( );
+
+ expect(screen.getByText(/must be changed before you can use the dashboard/)).toBeInTheDocument();
+ });
+
+ it("hides the forced-reset warning for a normal session", () => {
+ render( );
+
+ expect(screen.queryByText(/must be changed before you can use the dashboard/)).not.toBeInTheDocument();
+ });
+
+ it("signs the user out to re-login after a successful forced change", async () => {
+ mockPasswordResetRequired = true;
+ mockChangePasswordCall.mockResolvedValue({ user_id: "user-123", message: "Password updated successfully." });
+ const replaceMock = vi.fn();
+ const realLocation = window.location;
+ Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
+
+ try {
+ render( );
+ fillForm({ current: "OldP@ssw0rd-2026", next: "NewP@ssw0rd-2026", confirm: "NewP@ssw0rd-2026" });
+ submit();
+
+ await waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/ui/login/"));
+ expect(mockClearTokenCookies).toHaveBeenCalled();
+ } finally {
+ Object.defineProperty(window, "location", { configurable: true, value: realLocation });
+ }
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
index 4c51b7f3d15..b29f2a26389 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx
@@ -11,10 +11,12 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FieldGroup } from "@/components/ui/field";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
-import { changePasswordCall } from "@/components/networking";
+import { changePasswordCall, getProxyBaseUrl } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
+import { clearTokenCookies } from "@/utils/cookieUtils";
+import { getLoginUrl } from "@/utils/returnUrlUtils";
const changePasswordSchema = z
.object({
@@ -30,7 +32,7 @@ const changePasswordSchema = z
type ChangePasswordValues = z.infer;
export function ChangePasswordForm() {
- const { accessToken } = useAuthorized();
+ const { accessToken, passwordResetRequired } = useAuthorized();
const form = useZodForm(changePasswordSchema, {
defaultValues: { currentPassword: "", newPassword: "", confirmNewPassword: "" },
});
@@ -43,6 +45,13 @@ export function ChangePasswordForm() {
setIsPending(true);
try {
await changePasswordCall(accessToken, values.currentPassword, values.newPassword);
+ if (passwordResetRequired) {
+ // The session key was minted restricted; only a fresh login lifts it.
+ toast.success("Password updated. Please log in with your new password.");
+ clearTokenCookies();
+ window.location.replace(getLoginUrl(getProxyBaseUrl()));
+ return;
+ }
toast.success("Password updated");
form.reset();
} catch (error) {
@@ -62,6 +71,16 @@ export function ChangePasswordForm() {
policy.
+ {passwordResetRequired && (
+
+
+
+ Your password must be changed before you can use the dashboard. After updating it, you will be signed
+ out to log in again.
+
+
+ )}
+