mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
Merge commit 'af6dc1db08fd02399017014dd7530322d916b127' into fix/thinking-disabled-reasoning-content-gate
This commit is contained in:
commit
ddde64e210
58 changed files with 2741 additions and 601 deletions
|
|
@ -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);
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -1808,6 +1809,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):
|
||||
|
|
@ -1830,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
|
||||
|
|
@ -1860,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
|
||||
|
||||
|
|
@ -3791,6 +3814,12 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
|
|||
)
|
||||
|
||||
|
||||
class HTTPExceptionErrorDetail(TypedDict):
|
||||
"""The `{"error": <message>}` shape most proxy endpoints raise as `HTTPException.detail`."""
|
||||
|
||||
error: ReadOnly[str]
|
||||
|
||||
|
||||
class SpendLogsRouterMetadata(TypedDict):
|
||||
"""
|
||||
Router provenance stamped on spend logs for deployments flagged with
|
||||
|
|
|
|||
|
|
@ -10,14 +10,16 @@ import secrets
|
|||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, cast
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast
|
||||
|
||||
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 +29,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 +47,56 @@ from litellm.repositories.user_repository import UserRepository
|
|||
from litellm.secret_managers.main import get_secret_bool
|
||||
from litellm.types.proxy.ui_sso import ReturnedUITokenObject
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import types as prisma_types
|
||||
|
||||
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,
|
||||
) -> bool:
|
||||
"""Screens a successfully verified login password against HIBP, stamps
|
||||
``password_reset_required`` when breached, and returns whether a breach was
|
||||
found so the login it runs in can restrict the session it is about to mint.
|
||||
Fails open (HIBP or DB trouble never fails the login) and rechecks a given
|
||||
user at most once per ``BREACH_RECHECK_INTERVAL``."""
|
||||
if not is_breach_check_enabled(general_settings):
|
||||
return False
|
||||
if not _breach_recheck_due(last_breach_check_at):
|
||||
return False
|
||||
breached: Final = await is_password_breached(password, general_settings, client)
|
||||
checked_at: Final = datetime.now(timezone.utc)
|
||||
breached_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {
|
||||
"last_breach_check_at": checked_at,
|
||||
"password_reset_required": True,
|
||||
}
|
||||
recheck_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"last_breach_check_at": checked_at}
|
||||
update_data: Final = breached_update if breached else recheck_update
|
||||
find_user: Final[prisma_types.LiteLLM_UserTableWhereInput] = {"user_id": user_id}
|
||||
try:
|
||||
await UserRepository(prisma_client).table.update(where=find_user, data=update_data)
|
||||
except Exception as e: # noqa: BLE001 # 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)
|
||||
return breached
|
||||
|
||||
|
||||
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 +169,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 +178,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,20 +378,25 @@ async def authenticate_user(
|
|||
|
||||
if verify_password(password, _password):
|
||||
await _rehash_password_if_needed(_user_row.user_id, password, _password)
|
||||
breached_now: Final = prisma_client is not None and await 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 = breached_now or 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",
|
||||
**{
|
||||
"user_role": user_role,
|
||||
"duration": LITELLM_UI_SESSION_DURATION,
|
||||
"key_max_budget": litellm.max_ui_session_budget,
|
||||
"models": [],
|
||||
"aliases": {},
|
||||
"config": {},
|
||||
"spend": 0,
|
||||
"user_id": user_id,
|
||||
"team_id": "litellm-dashboard",
|
||||
},
|
||||
user_role=user_role,
|
||||
duration=LITELLM_UI_SESSION_DURATION,
|
||||
key_max_budget=litellm.max_ui_session_budget,
|
||||
spend=0,
|
||||
user_id=user_id,
|
||||
team_id="litellm-dashboard",
|
||||
allowed_routes=list(PASSWORD_RESET_ALLOWED_ROUTES) if password_reset_required else None,
|
||||
metadata={"password_reset_required": True} if password_reset_required else {},
|
||||
)
|
||||
else:
|
||||
raise ProxyException(
|
||||
|
|
@ -353,6 +414,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 +488,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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,28 @@ 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.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import asyncio
|
||||
import hashlib
|
||||
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
|
||||
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_TIMEOUT_SECONDS: Final = 5.0
|
||||
|
||||
DEFAULT_MIN_LENGTH: Final = 12
|
||||
MIN_ALLOWED_LENGTH: Final = 8
|
||||
|
|
@ -90,3 +105,114 @@ 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}, # mutable-ok: callee takes a bare dict (PEP 589)
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
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=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
breached: Final = _is_suffix_in_range_response(response.text, sha1_hex[5:])
|
||||
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
|
||||
|
||||
|
||||
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],
|
||||
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."""
|
||||
if not await is_password_breached(password, general_settings, client):
|
||||
return
|
||||
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
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
@ -796,7 +806,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 +827,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 +849,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).
|
||||
|
|
|
|||
|
|
@ -74,10 +74,14 @@ class LatestHealthCheckRow(BaseModel):
|
|||
_ROWS_ADAPTER: Final = TypeAdapter(tuple[LatestHealthCheckRow, ...])
|
||||
|
||||
|
||||
async def query_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]:
|
||||
rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL)
|
||||
return _ROWS_ADAPTER.validate_python(rows)
|
||||
|
||||
|
||||
async def fetch_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]:
|
||||
try:
|
||||
rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL)
|
||||
return _ROWS_ADAPTER.validate_python(rows)
|
||||
return await query_latest_health_checks(prisma_client)
|
||||
except Exception as query_err: # noqa: BLE001 # health decorates other reads; a driver error must not fail them
|
||||
verbose_proxy_logger.error("Error getting all latest health checks: %s", query_err)
|
||||
return ()
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@ from litellm.proxy.auth.auth_utils import (
|
|||
from litellm.proxy.auth.model_checks import get_key_models
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
|
||||
from litellm.proxy.db.health_check_latest import LatestHealthCheckRow
|
||||
from litellm.proxy.db.health_check_latest import (
|
||||
LatestHealthCheckRow,
|
||||
query_latest_health_checks,
|
||||
)
|
||||
from litellm.proxy.db.proxy_worker_heartbeat import count_live_proxy_workers
|
||||
from litellm.proxy.health_check import (
|
||||
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS,
|
||||
|
|
@ -876,7 +879,7 @@ async def _save_background_health_checks_to_db(
|
|||
)
|
||||
|
||||
# Step 3: Get latest health checks for all models in one query to compare status
|
||||
latest_checks: Final = await prisma_client.get_all_latest_health_checks()
|
||||
latest_checks: Final = await query_latest_health_checks(prisma_client)
|
||||
latest_checks_map: Final = {}
|
||||
for check in latest_checks:
|
||||
# Use model_id as primary key, fallback to model_name
|
||||
|
|
|
|||
|
|
@ -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_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,23 @@ def _team_membership_table(
|
|||
return team_membership_table
|
||||
|
||||
|
||||
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).
|
||||
|
||||
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:
|
||||
validate_password_policy(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"])
|
||||
data["password_reset_required"] = True
|
||||
data["last_breach_check_at"] = None
|
||||
|
||||
|
||||
def _strip_password_from_response(response) -> None:
|
||||
|
|
@ -494,6 +511,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.
|
||||
|
|
@ -513,7 +531,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)
|
||||
|
|
@ -561,7 +579,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)
|
||||
teams = data.teams
|
||||
if teams is None:
|
||||
teams = check_if_default_team_set()
|
||||
|
|
@ -1427,6 +1445,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.
|
||||
|
|
@ -1449,7 +1468,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, password_prevalidated=password_prevalidated)
|
||||
|
||||
existing_user_row: BaseModel | None = None
|
||||
if user_request.user_id:
|
||||
|
|
@ -1630,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] - Specify a user password.
|
||||
- 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.
|
||||
|
|
@ -1698,19 +1717,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(
|
||||
|
|
@ -1848,6 +1886,14 @@ 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:
|
||||
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"})
|
||||
|
||||
|
|
|
|||
126
litellm/proxy/management_endpoints/password_endpoints.py
Normal file
126
litellm/proxy/management_endpoints/password_endpoints.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""
|
||||
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,
|
||||
HTTPExceptionErrorDetail,
|
||||
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 prisma import types as prisma_types
|
||||
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
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]":
|
||||
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). A successful change lifts any pending forced
|
||||
password reset (`password_reset_required`) on the account.
|
||||
|
||||
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_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_detail("No user is associated with this session, so there is no password to change."),
|
||||
)
|
||||
|
||||
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_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_detail("Current password is incorrect."))
|
||||
|
||||
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_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)
|
||||
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.")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -15033,6 +15036,13 @@ def _get_proxy_model_info(model: dict) -> dict:
|
|||
return _translate_model_name_for_response(model)
|
||||
|
||||
|
||||
def _model_info_json_response(data: Sequence[Mapping[str, object]] | Mapping[str, object]) -> Response:
|
||||
return Response(
|
||||
content=orjson.dumps({"data": data}, default=jsonable_encoder, option=orjson.OPT_NON_STR_KEYS),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/model/info",
|
||||
tags=["model management"],
|
||||
|
|
@ -15080,7 +15090,7 @@ async def model_info_v1(
|
|||
`model_info.direct_access` when the proxy database is connected.
|
||||
|
||||
Returns:
|
||||
Returns a dictionary containing information about each model.
|
||||
A JSON response whose `data` list holds one entry per model.
|
||||
|
||||
Example Response:
|
||||
```json
|
||||
|
|
@ -15128,7 +15138,7 @@ async def model_info_v1(
|
|||
deployment_dict=_deployment_info_dict,
|
||||
excluded_keys={"litellm_credential_name"},
|
||||
)
|
||||
return {"data": _deployment_info_dict}
|
||||
return _model_info_json_response(_deployment_info_dict)
|
||||
|
||||
if llm_model_list is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -15179,7 +15189,7 @@ async def model_info_v1(
|
|||
llm_router=llm_router,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
return {"data": single_model_list}
|
||||
return _model_info_json_response(single_model_list)
|
||||
|
||||
# Return router deployments (same source as /v2/model/info), not wildcard-
|
||||
# expanded model names from get_complete_model_list(). Team-scoped rows
|
||||
|
|
@ -15247,7 +15257,7 @@ async def model_info_v1(
|
|||
visible_models: Final = [model for model in all_models if model.get("model_name") not in hidden_names]
|
||||
|
||||
verbose_proxy_logger.debug("all_models: %s", visible_models)
|
||||
return {"data": visible_models}
|
||||
return _model_info_json_response(visible_models)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -16190,6 +16200,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),
|
||||
|
|
@ -16299,6 +16310,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(
|
||||
|
|
@ -16369,6 +16381,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:
|
||||
|
|
@ -16388,7 +16401,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:
|
||||
|
|
@ -18764,6 +18782,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)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -10683,6 +10683,10 @@ class Router:
|
|||
model_list: Final = self.get_model_list(model_name=model_group)
|
||||
if model_list is None:
|
||||
return None
|
||||
model_group_description: Final[str | None] = next(
|
||||
(d for d in (m.get("model_info", {}).get("description") for m in model_list) if isinstance(d, str)),
|
||||
None,
|
||||
)
|
||||
for model in model_list:
|
||||
is_match = False
|
||||
if (
|
||||
|
|
@ -10787,6 +10791,7 @@ class Router:
|
|||
**{
|
||||
"model_group": user_facing_model_group_name,
|
||||
"providers": [llm_provider],
|
||||
"description": model_group_description,
|
||||
**model_info,
|
||||
"supported_reasoning_efforts": None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -721,6 +721,7 @@ class ModelGroupInfo(BaseModel):
|
|||
supported_reasoning_efforts: tuple[str, ...] | None = Field(default=None)
|
||||
supported_openai_params: list[str] | None = Field(default=[])
|
||||
configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
|
||||
description: str | None = None
|
||||
|
||||
def __init__(self, **data) -> None:
|
||||
for field_name, field_type in _resolved_annotations(self.__class__).items():
|
||||
|
|
|
|||
|
|
@ -1260,15 +1260,6 @@ async def _client_async_logging_helper(
|
|||
async_coroutine=logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time)
|
||||
)
|
||||
|
||||
################################################
|
||||
# Sync Logging Worker
|
||||
################################################
|
||||
logging_obj.handle_sync_success_callbacks_for_async_calls(
|
||||
result=result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
|
||||
def _get_wrapper_num_retries(kwargs: dict[str, Any], exception: Exception) -> tuple[int | None, dict[str, Any]]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ reason = "diskcache has no fixed release published; remove this entry once one e
|
|||
|
||||
[[IgnoredVulns]]
|
||||
id = "GHSA-h7x2-h6g9-p789"
|
||||
ignoreUntil = 2026-09-14
|
||||
reason = "mlflow has no fixed release published; remove this entry once one exists"
|
||||
ignoreUntil = 2026-10-14
|
||||
reason = "mlflow has no fixed release published (3.16.0, 2026-09-04, and master still store gateway secret api_base unvalidated); remove this entry once one exists"
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -2139,7 +2139,7 @@ async def test_model_info_alias_without_prisma(hidden):
|
|||
user_api_key_dict=UserAPIKeyAuth(models=[]),
|
||||
)
|
||||
|
||||
models = resp["data"]
|
||||
models = json.loads(resp.body)["data"]
|
||||
|
||||
alias_found = any(
|
||||
m["model_name"] == model_alias
|
||||
|
|
@ -2203,7 +2203,7 @@ async def test_proxy_model_group_alias_checks(prisma_client, hidden): # noqa: F
|
|||
resp = await model_info_v1(
|
||||
user_api_key_dict=UserAPIKeyAuth(models=[]),
|
||||
)
|
||||
models = resp["data"]
|
||||
models = json.loads(resp.body)["data"]
|
||||
is_model_alias_in_list = False
|
||||
for item in models:
|
||||
if model_alias == item["model_name"]:
|
||||
|
|
@ -2280,7 +2280,7 @@ async def test_proxy_model_group_info_rerank(prisma_client): # noqa: F811 # py
|
|||
resp = await model_info_v1(
|
||||
user_api_key_dict=UserAPIKeyAuth(models=[]),
|
||||
)
|
||||
models = resp["data"]
|
||||
models = json.loads(resp.body)["data"]
|
||||
assert models[0]["model_info"]["mode"] == "rerank"
|
||||
resp = await model_group_info(
|
||||
user_api_key_dict=UserAPIKeyAuth(models=[]),
|
||||
|
|
|
|||
|
|
@ -93,9 +93,7 @@ class TestCredentials:
|
|||
assert item.credential_values is None
|
||||
|
||||
def test_create_credential_item_requires_values_or_model_id(self):
|
||||
with pytest.raises(
|
||||
ValueError, match="Either credential_values or model_id must be set"
|
||||
):
|
||||
with pytest.raises(ValueError, match="Either credential_values or model_id must be set"):
|
||||
CreateCredentialItem(credential_name="bad", credential_info={})
|
||||
|
||||
|
||||
|
|
@ -113,12 +111,8 @@ class TestModel:
|
|||
assert model.team_public_model_name == "my-gpt4"
|
||||
|
||||
def test_is_blocked(self):
|
||||
model_blocked = LiteLLM_ProxyModelTable(
|
||||
model_id="m1", model_name="test", litellm_params={}, blocked=True
|
||||
)
|
||||
model_unblocked = LiteLLM_ProxyModelTable(
|
||||
model_id="m2", model_name="test", litellm_params={}, blocked=False
|
||||
)
|
||||
model_blocked = LiteLLM_ProxyModelTable(model_id="m1", model_name="test", litellm_params={}, blocked=True)
|
||||
model_unblocked = LiteLLM_ProxyModelTable(model_id="m2", model_name="test", litellm_params={}, blocked=False)
|
||||
assert model_blocked.is_blocked
|
||||
assert not model_unblocked.is_blocked
|
||||
|
||||
|
|
@ -160,9 +154,7 @@ class TestModel:
|
|||
assert model.blocked is True
|
||||
|
||||
def test_team_helpers_none_when_no_model_info(self):
|
||||
model = LiteLLM_ProxyModelTable(
|
||||
model_id="m1", model_name="gpt-4", litellm_params={}, model_info=None
|
||||
)
|
||||
model = LiteLLM_ProxyModelTable(model_id="m1", model_name="gpt-4", litellm_params={}, model_info=None)
|
||||
assert model.team_id is None
|
||||
assert model.team_public_model_name is None
|
||||
|
||||
|
|
@ -264,9 +256,7 @@ class TestTeam:
|
|||
assert team.model_max_budget == {"gpt-4": 5.0}
|
||||
|
||||
def test_cached_team(self):
|
||||
cached = LiteLLM_TeamTableCachedObj(
|
||||
team_id="t1", last_refreshed_at=1234567890.0
|
||||
)
|
||||
cached = LiteLLM_TeamTableCachedObj(team_id="t1", last_refreshed_at=1234567890.0)
|
||||
assert cached.last_refreshed_at == 1234567890.0
|
||||
|
||||
def test_deleted_team(self):
|
||||
|
|
@ -308,6 +298,8 @@ class TestUser:
|
|||
assert user_no_models.has_model_access("any-model")
|
||||
|
||||
def test_password_hash_excluded_from_serialization(self):
|
||||
import json
|
||||
|
||||
from litellm.proxy._types import LiteLLM_UserTableWithKeyCount
|
||||
|
||||
secret = "$2b$12$abcdefghijklmnopqrstuv"
|
||||
|
|
@ -315,14 +307,12 @@ class TestUser:
|
|||
|
||||
assert user.password == secret
|
||||
assert "password" not in user.model_dump()
|
||||
assert "password" not in user.model_dump_json()
|
||||
assert "password" not in json.loads(user.model_dump_json())
|
||||
|
||||
with_keys = LiteLLM_UserTableWithKeyCount(
|
||||
user_id="u1", user_email="a@b.c", password=secret, key_count=2
|
||||
)
|
||||
with_keys = LiteLLM_UserTableWithKeyCount(user_id="u1", user_email="a@b.c", password=secret, key_count=2)
|
||||
assert with_keys.password == secret
|
||||
assert "password" not in with_keys.model_dump()
|
||||
assert "password" not in with_keys.model_dump_json()
|
||||
assert "password" not in json.loads(with_keys.model_dump_json())
|
||||
|
||||
|
||||
class TestVerificationToken:
|
||||
|
|
@ -443,9 +433,7 @@ class TestEndUserTable:
|
|||
class TestBudgetTableFull:
|
||||
def test_full_adds_server_managed_fields(self):
|
||||
now = datetime.now()
|
||||
budget = LiteLLM_BudgetTableFull(
|
||||
budget_id="b1", max_budget=10.0, created_at=now, budget_reset_at=now
|
||||
)
|
||||
budget = LiteLLM_BudgetTableFull(budget_id="b1", max_budget=10.0, created_at=now, budget_reset_at=now)
|
||||
assert budget.created_at == now
|
||||
assert budget.budget_reset_at == now
|
||||
assert budget.max_budget == 10.0
|
||||
|
|
@ -457,9 +445,7 @@ class TestBudgetTableFull:
|
|||
|
||||
class TestTeamMemberTable:
|
||||
def test_tracks_user_within_team(self):
|
||||
member = LiteLLM_TeamMemberTable(
|
||||
user_id="u1", team_id="t1", spend=3.0, budget_id="b1", max_budget=5.0
|
||||
)
|
||||
member = LiteLLM_TeamMemberTable(user_id="u1", team_id="t1", spend=3.0, budget_id="b1", max_budget=5.0)
|
||||
assert member.user_id == "u1"
|
||||
assert member.team_id == "t1"
|
||||
assert member.spend == 3.0
|
||||
|
|
@ -549,9 +535,7 @@ class TestSpendLogs:
|
|||
assert log.updated_at == updated_at
|
||||
|
||||
def test_error_logs_creation(self):
|
||||
log = LiteLLM_ErrorLogs(
|
||||
request_id="r1", startTime=None, endTime=None, status_code="500"
|
||||
)
|
||||
log = LiteLLM_ErrorLogs(request_id="r1", startTime=None, endTime=None, status_code="500")
|
||||
assert log.request_id == "r1"
|
||||
assert log.status_code == "500"
|
||||
|
||||
|
|
@ -569,9 +553,7 @@ class TestManagedTables:
|
|||
|
||||
def test_managed_object_table_requires_purpose(self):
|
||||
with pytest.raises(ValidationError):
|
||||
LiteLLM_ManagedObjectTable(
|
||||
unified_object_id="o1", model_object_id="m1", file_object={}
|
||||
)
|
||||
LiteLLM_ManagedObjectTable(unified_object_id="o1", model_object_id="m1", file_object={})
|
||||
|
||||
def test_managed_vector_stores_table(self):
|
||||
table = LiteLLM_ManagedVectorStoresTable(
|
||||
|
|
|
|||
|
|
@ -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,263 @@ 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( # test-quality-ok: asserting the minted key's restriction requires seeing its kwargs
|
||||
"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 key_kwargs["allowed_routes"] is None
|
||||
assert not key_kwargs["metadata"]
|
||||
assert result.password_reset_required is False
|
||||
|
||||
async def _login_with_screen_result(self, mock_prisma_client, breached: bool) -> tuple[LoginResult, dict, dict]:
|
||||
with patch.dict(os.environ, _DB_LOGIN_ENV):
|
||||
with patch( # test-quality-ok: asserting the minted key's restriction requires seeing its kwargs
|
||||
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"token": "session-token"},
|
||||
) as mock_generate_key:
|
||||
with (
|
||||
patch( # test-quality-ok: authenticate_user has no HIBP client seam; the screen itself is tested against MockTransport below
|
||||
"litellm.proxy.auth.login_utils.screen_login_password_for_breach",
|
||||
new_callable=AsyncMock,
|
||||
return_value=breached,
|
||||
) as mock_screen
|
||||
):
|
||||
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, mock_screen.call_args.kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_screens_with_row_state_before_minting(self):
|
||||
"""The login must hand the 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)
|
||||
|
||||
_, _, screen_kwargs = await self._login_with_screen_result(mock_prisma_client, breached=False)
|
||||
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_breach_hit_restricts_the_current_session(self):
|
||||
"""A breach found during THIS login must restrict THIS session, not
|
||||
just the next one."""
|
||||
row = _db_user_row(password="Str0ng!Passw0rd", password_reset_required=None)
|
||||
mock_prisma_client = _prisma_with_user(row)
|
||||
|
||||
result, key_kwargs, _ = await self._login_with_screen_result(mock_prisma_client, breached=True)
|
||||
|
||||
assert key_kwargs["allowed_routes"] == ["/user/password/change"]
|
||||
assert key_kwargs["metadata"] == {"password_reset_required": True}
|
||||
assert result.password_reset_required is True
|
||||
|
||||
|
||||
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 awaited login-time screen: flags a breached password for a forced
|
||||
reset, stamps the recheck timestamp, rechecks at most every 24h, returns
|
||||
the breach verdict so the login can restrict the session it is minting,
|
||||
and never raises into the login."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breached_password_sets_reset_flag_and_timestamp(self):
|
||||
password = "Password123!"
|
||||
mock_prisma_client = _prisma_with_user(None)
|
||||
|
||||
breached = 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),
|
||||
)
|
||||
|
||||
assert breached is True
|
||||
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)
|
||||
|
||||
breached = 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(),
|
||||
)
|
||||
|
||||
assert breached is False
|
||||
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)
|
||||
|
||||
breached = 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(),
|
||||
)
|
||||
|
||||
assert breached is False
|
||||
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)
|
||||
|
||||
breached = 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 breached is True
|
||||
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)
|
||||
|
||||
breached = 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(),
|
||||
)
|
||||
|
||||
assert breached is False
|
||||
mock_prisma_client.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_failure_never_raises_but_still_reports_the_breach(self):
|
||||
"""A failed flag write must not fail the login, but the breach verdict
|
||||
still has to restrict the session being minted right now."""
|
||||
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 True
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -454,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()
|
||||
|
|
@ -483,7 +496,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 +520,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()
|
||||
|
|
|
|||
|
|
@ -2,22 +2,56 @@
|
|||
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 asyncio
|
||||
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,
|
||||
validate_passwords_bulk,
|
||||
)
|
||||
|
||||
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 +168,178 @@ 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
|
||||
|
||||
|
||||
@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 == {}
|
||||
|
|
|
|||
|
|
@ -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,136 @@ 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
|
||||
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from litellm.proxy.db.health_check_latest import (
|
|||
LATEST_HEALTH_CHECKS_SQL,
|
||||
fetch_latest_health_checks,
|
||||
fetch_latest_health_checks_for_models,
|
||||
query_latest_health_checks,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -83,6 +84,15 @@ async def test_fetch_all_degrades_to_no_rows_when_the_query_fails():
|
|||
assert await fetch_latest_health_checks(prisma) == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_all_raises_when_the_query_fails_instead_of_reading_as_an_empty_table():
|
||||
"""The background save decides what to write from this read; a failure has to be told apart from no rows."""
|
||||
prisma = _prisma([])
|
||||
prisma.db.query_raw.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(RuntimeError, match="db down"):
|
||||
await query_latest_health_checks(prisma)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_degrades_to_no_rows_for_a_malformed_row():
|
||||
assert await fetch_latest_health_checks(_prisma([{"unexpected": "shape"}])) == ()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,331 @@
|
|||
"""
|
||||
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( # 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),
|
||||
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)
|
||||
# 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
|
||||
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( # 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(
|
||||
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( # 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(
|
||||
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( # 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(
|
||||
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( # 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(
|
||||
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( # 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(
|
||||
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( # 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(
|
||||
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( # 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),
|
||||
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( # 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(
|
||||
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( # 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(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
|
@ -9,6 +9,7 @@ rows instead of the internal routing key `model_name_{team_id}_{uuid}`.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -238,7 +239,7 @@ async def test_model_info_v1_list_path_translates_team_model_name(monkeypatch):
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=admin, litellm_model_id=None)
|
||||
|
||||
names = [m["model_name"] for m in resp["data"]]
|
||||
names = [m["model_name"] for m in json.loads(resp.body)["data"]]
|
||||
assert "team-claude-sonnet" in names
|
||||
assert "model_name_team-abc-123_4a6b8" not in names
|
||||
|
||||
|
|
@ -271,7 +272,7 @@ async def test_model_info_v1_unrestricted_key_returns_all_deployments(monkeypatc
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
assert [m["model_name"] for m in resp["data"]] == ["gpt-4"]
|
||||
assert [m["model_name"] for m in json.loads(resp.body)["data"]] == ["gpt-4"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -303,7 +304,7 @@ async def test_model_info_v1_restricted_key_filters_deployments(monkeypatch):
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
assert [m["model_name"] for m in resp["data"]] == ["gpt-4"]
|
||||
assert [m["model_name"] for m in json.loads(resp.body)["data"]] == ["gpt-4"]
|
||||
|
||||
|
||||
def _other_team_row() -> dict:
|
||||
|
|
@ -367,10 +368,11 @@ async def test_model_info_v1_unrestricted_key_hides_other_team_byok(monkeypatch)
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
returned_ids = {m["model_info"]["id"] for m in resp["data"]}
|
||||
data = json.loads(resp.body)["data"]
|
||||
returned_ids = {m["model_info"]["id"] for m in data}
|
||||
assert returned_ids == {"global-id-1", "byok-id-1"}
|
||||
assert "byok-id-other" not in returned_ids
|
||||
names = [m["model_name"] for m in resp["data"]]
|
||||
names = [m["model_name"] for m in data]
|
||||
assert "team-claude-sonnet" in names
|
||||
assert "gpt-4" in names
|
||||
|
||||
|
|
@ -412,7 +414,7 @@ async def test_model_info_v1_service_key_hides_all_team_byok(monkeypatch):
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
assert [m["model_info"]["id"] for m in resp["data"]] == ["global-id-1"]
|
||||
assert [m["model_info"]["id"] for m in json.loads(resp.body)["data"]] == ["global-id-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -466,7 +468,7 @@ async def test_model_info_v1_team_key_sees_own_byok_regardless_of_user_lookup(
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
assert [m["model_info"]["id"] for m in resp["data"]] == ["byok-id-1", "global-id-1"]
|
||||
assert [m["model_info"]["id"] for m in json.loads(resp.body)["data"]] == ["byok-id-1", "global-id-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -509,7 +511,7 @@ async def test_model_info_v1_user_team_membership_grants_byok(monkeypatch):
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
|
||||
|
||||
assert [m["model_info"]["id"] for m in resp["data"]] == [
|
||||
assert [m["model_info"]["id"] for m in json.loads(resp.body)["data"]] == [
|
||||
"byok-id-other",
|
||||
"global-id-1",
|
||||
]
|
||||
|
|
@ -557,7 +559,7 @@ async def test_model_info_v1_populates_access_via_team_ids(monkeypatch):
|
|||
)
|
||||
resp = await ps.model_info_v1(user_api_key_dict=admin, litellm_model_id=None)
|
||||
|
||||
by_id = {m["model_info"]["id"]: m for m in resp["data"]}
|
||||
by_id = {m["model_info"]["id"]: m for m in json.loads(resp.body)["data"]}
|
||||
assert by_id["byok-id-1"]["model_info"]["access_via_team_ids"] == [team_id]
|
||||
assert by_id["byok-id-1"]["model_info"]["direct_access"] is False
|
||||
assert by_id["global-id-1"]["model_info"]["direct_access"] is True
|
||||
|
|
@ -816,7 +818,7 @@ async def test_model_info_v1_litellm_model_id_include_team_models_filters_inacce
|
|||
include_team_models=True,
|
||||
)
|
||||
|
||||
assert resp["data"] == []
|
||||
assert json.loads(resp.body)["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -852,7 +854,7 @@ async def test_model_info_v1_litellm_model_id_team_id_applies_team_filter(monkey
|
|||
teamId="other-team",
|
||||
)
|
||||
|
||||
assert resp["data"] == []
|
||||
assert json.loads(resp.body)["data"] == []
|
||||
team_filter.assert_awaited_once()
|
||||
assert team_filter.await_args.kwargs["team_id"] == "other-team"
|
||||
assert team_filter.await_args.kwargs["all_models"] == [team_row]
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ from pydantic import ValidationError
|
|||
|
||||
from litellm.proxy._types import (
|
||||
ROLES_WITHIN_ORG,
|
||||
ChangePasswordRequest,
|
||||
GenerateKeyRequest,
|
||||
KeyRequest,
|
||||
LiteLLM_AuditLogs,
|
||||
LiteLLM_TeamMembership,
|
||||
LitellmUserRoles,
|
||||
NewUserRequest,
|
||||
OrganizationMemberUpdateRequest,
|
||||
ResetSpendRequest,
|
||||
UpdateKeyRequest,
|
||||
|
|
@ -277,3 +279,43 @@ 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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ async def test_save_background_health_checks_to_db():
|
|||
"""Test the main background health check save function"""
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.save_health_check_result = AsyncMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
|
||||
model_list = [
|
||||
{
|
||||
|
|
@ -398,9 +398,9 @@ async def test_save_background_health_checks_to_db():
|
|||
"background_health_check",
|
||||
)
|
||||
|
||||
# Should call get_all_latest_health_checks and save_health_check_result, and report completion
|
||||
# Should read the latest rows and save_health_check_result, and report completion
|
||||
assert persisted is True
|
||||
mock_prisma.get_all_latest_health_checks.assert_called_once()
|
||||
mock_prisma.db.query_raw.assert_awaited_once()
|
||||
mock_prisma.save_health_check_result.assert_called_once()
|
||||
|
||||
call_kwargs = mock_prisma.save_health_check_result.call_args[1]
|
||||
|
|
@ -493,7 +493,7 @@ def _one_model_setup():
|
|||
@pytest.mark.asyncio
|
||||
async def test_save_background_health_checks_to_db_returns_false_when_a_write_fails():
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
mock_prisma.save_health_check_result = AsyncMock(return_value=None)
|
||||
model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup()
|
||||
|
||||
|
|
@ -504,6 +504,23 @@ async def test_save_background_health_checks_to_db_returns_false_when_a_write_fa
|
|||
assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_background_health_checks_to_db_writes_nothing_when_the_latest_row_read_fails(mock_prisma):
|
||||
"""
|
||||
A failed dedup read must not read as an empty table. Treated that way, every model was written on every
|
||||
cycle by every pod while the read kept failing, which is what filled the table in production.
|
||||
"""
|
||||
mock_prisma.db.query_raw = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
mock_prisma.save_health_check_result = AsyncMock(return_value={"id": "row"})
|
||||
model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup()
|
||||
|
||||
persisted = await _save_background_health_checks_to_db(
|
||||
mock_prisma, model_list, healthy_endpoints, unhealthy_endpoints, 1234567890.0, "background_health_check"
|
||||
)
|
||||
|
||||
assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_background_health_checks_to_db_no_prisma():
|
||||
"""Test graceful handling when no prisma client"""
|
||||
|
|
@ -515,7 +532,7 @@ async def test_save_background_health_checks_to_db_no_prisma():
|
|||
async def test_save_background_health_checks_to_db_exception_handling():
|
||||
"""Test exception handling in background health check save"""
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.get_all_latest_health_checks = AsyncMock(side_effect=Exception("DB Error"))
|
||||
mock_prisma.db.query_raw = AsyncMock(side_effect=Exception("DB Error"))
|
||||
|
||||
model_list = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ Tests verifying that default_api_key_tpm_limit and default_api_key_rpm_limit set
|
|||
litellm_params are returned by the /model/info endpoint.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -128,8 +129,9 @@ class TestModelInfoEndpointWithRouter:
|
|||
litellm_model_id="some-model-id",
|
||||
)
|
||||
|
||||
assert len(response["data"]) == 1
|
||||
litellm_params = response["data"][0]["litellm_params"]
|
||||
data = json.loads(response.body)["data"]
|
||||
assert len(data) == 1
|
||||
litellm_params = data[0]["litellm_params"]
|
||||
assert litellm_params.get("default_api_key_tpm_limit") == 100
|
||||
assert litellm_params.get("default_api_key_rpm_limit") == 200
|
||||
|
||||
|
|
@ -171,7 +173,8 @@ class TestModelInfoEndpointWithRouter:
|
|||
litellm_model_id=None,
|
||||
)
|
||||
|
||||
assert len(response["data"]) >= 1
|
||||
litellm_params = response["data"][0]["litellm_params"]
|
||||
data = json.loads(response.body)["data"]
|
||||
assert len(data) >= 1
|
||||
litellm_params = data[0]["litellm_params"]
|
||||
assert litellm_params.get("default_api_key_tpm_limit") == 100
|
||||
assert litellm_params.get("default_api_key_rpm_limit") == 200
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ per-request `healthy_only` query parameter and the proxy-wide
|
|||
(`model_info_v1`).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -275,7 +276,7 @@ async def test_model_info_v1_healthy_only_hides_unhealthy_deployments(
|
|||
litellm_model_id=None,
|
||||
healthy_only=True,
|
||||
)
|
||||
assert [m["model_name"] for m in response["data"]] == ["gpt-4"]
|
||||
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["gpt-4"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -286,7 +287,7 @@ async def test_model_info_v1_general_setting_hides_unhealthy_deployments(patched
|
|||
user_api_key_dict=_admin_key(),
|
||||
litellm_model_id=None,
|
||||
)
|
||||
assert [m["model_name"] for m in response["data"]] == ["gpt-4"]
|
||||
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["gpt-4"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -297,7 +298,7 @@ async def test_model_info_v1_default_keeps_unhealthy_deployments(
|
|||
user_api_key_dict=_admin_key(),
|
||||
litellm_model_id=None,
|
||||
)
|
||||
assert [m["model_name"] for m in response["data"]] == ["gpt-4", "claude-sonnet"]
|
||||
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["gpt-4", "claude-sonnet"]
|
||||
patched_model_info_v1.async_get_fully_unhealthy_model_names.assert_not_awaited()
|
||||
|
||||
|
||||
|
|
@ -318,4 +319,4 @@ async def test_model_info_v1_litellm_model_id_lookup_ignores_health_filter(patch
|
|||
user_api_key_dict=_admin_key(),
|
||||
litellm_model_id="unhealthy-id",
|
||||
)
|
||||
assert [m["model_name"] for m in response["data"]] == ["claude-sonnet"]
|
||||
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["claude-sonnet"]
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ from unittest import mock
|
|||
from unittest.mock import AsyncMock, MagicMock, create_autospec, mock_open, patch
|
||||
|
||||
import click
|
||||
import fastapi.routing
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import FastAPI
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
|
@ -5247,6 +5249,8 @@ async def test_model_info_v1_oci_secrets_not_leaked():
|
|||
result = await model_info_v1(user_api_key_dict=mock_user_api_key_dict, litellm_model_id=None)
|
||||
|
||||
# Verify the result structure
|
||||
result_str = result.body.decode()
|
||||
result = json.loads(result_str)
|
||||
assert "data" in result
|
||||
assert len(result["data"]) == 1
|
||||
|
||||
|
|
@ -5269,13 +5273,96 @@ async def test_model_info_v1_oci_secrets_not_leaked():
|
|||
assert litellm_params["model"].startswith("oci/"), "model should retain its full value"
|
||||
|
||||
# Verify that actual secret values are not present in the response
|
||||
result_str = str(result)
|
||||
assert "ocid1.api_key.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk" not in result_str
|
||||
assert "aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99:00" not in result_str
|
||||
assert "ocid1.tenancy.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk" not in result_str
|
||||
assert "/path/to/oci_api_key.pem" not in result_str
|
||||
|
||||
|
||||
def test_model_info_v1_list_skips_fastapi_jsonable_encoder(monkeypatch):
|
||||
"""
|
||||
/model/info serializes its multi-megabyte listing itself with orjson. FastAPI must not
|
||||
re-walk the payload through `jsonable_encoder`, while values orjson cannot encode natively
|
||||
still come out as JSON.
|
||||
"""
|
||||
created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
||||
model_data = {
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-secret-value"},
|
||||
"model_info": {
|
||||
"id": "db-row-1",
|
||||
"db_model": True,
|
||||
"created_at": created_at,
|
||||
"supported_regions": frozenset({"eu"}),
|
||||
},
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_router.model_list = [model_data]
|
||||
mock_router.get_model_list_from_model_alias.return_value = []
|
||||
mock_router.get_model_names.return_value = ["gpt-4o"]
|
||||
mock_router.get_model_access_groups.return_value = {}
|
||||
mock_router.get_deployment.return_value = None
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_model_list", [model_data])
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"infer_model_from_keys": False})
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
|
||||
|
||||
encoder_spy = MagicMock(wraps=jsonable_encoder)
|
||||
monkeypatch.setattr(fastapi.routing, "jsonable_encoder", encoder_spy)
|
||||
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234", models=[], team_models=[]
|
||||
)
|
||||
client = TestClient(app)
|
||||
try:
|
||||
response = client.get("/model/info")
|
||||
finally:
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/json"
|
||||
rows = response.json()["data"]
|
||||
assert [row["model_name"] for row in rows] == ["gpt-4o"]
|
||||
assert rows[0]["model_info"]["created_at"] == created_at.isoformat()
|
||||
assert rows[0]["model_info"]["supported_regions"] == ["eu"]
|
||||
assert "sk-secret-value" not in response.text
|
||||
assert encoder_spy.call_count == 0
|
||||
|
||||
|
||||
def test_model_info_v1_cli_model_returns_single_deployment_as_json(monkeypatch):
|
||||
"""
|
||||
A proxy started with `litellm --model <name>` answers /model/info with one deployment
|
||||
object under `data`, serialized the same way as the listing.
|
||||
"""
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", "gpt-4o")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_model_list", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
|
||||
encoder_spy = MagicMock(wraps=jsonable_encoder)
|
||||
monkeypatch.setattr(fastapi.routing, "jsonable_encoder", encoder_spy)
|
||||
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234", models=[], team_models=[]
|
||||
)
|
||||
client = TestClient(app)
|
||||
try:
|
||||
response = client.get("/model/info")
|
||||
finally:
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/json"
|
||||
deployment = response.json()["data"]
|
||||
assert deployment["model_name"] == "*"
|
||||
assert deployment["litellm_params"]["model"] == "gpt-4o"
|
||||
assert encoder_spy.call_count == 0
|
||||
|
||||
|
||||
def test_add_callback_from_db_to_in_memory_litellm_callbacks():
|
||||
"""
|
||||
Test that _add_callback_from_db_to_in_memory_litellm_callbacks correctly adds callbacks
|
||||
|
|
|
|||
|
|
@ -1790,6 +1790,31 @@ def test_model_group_info_cost_none_when_db_model_info_has_no_cost():
|
|||
assert result.output_cost_per_token is None
|
||||
|
||||
|
||||
def test_model_group_info_description_from_model_info():
|
||||
"""
|
||||
model_info.description set in the config should surface on ModelGroupInfo,
|
||||
including when only a later deployment in the group carries it.
|
||||
"""
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "gpt-4", "api_key": "fake"},
|
||||
"model_info": {},
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "gpt-4", "api_key": "fake2"},
|
||||
"model_info": {"description": "State-of-the-art language model."},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = router.get_model_group_info("gpt-4")
|
||||
assert result is not None
|
||||
assert result.description == "State-of-the-art language model."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import os
|
|||
import queue
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from collections.abc import Callable, Iterator
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ from litellm._logging import (
|
|||
verbose_logger,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.thread_pool_executor import executor as logging_executor
|
||||
from litellm.proxy.utils import is_valid_api_key
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
|
|
@ -6444,6 +6445,53 @@ async def test_acompletion_finishes_response_metadata_before_handing_the_respons
|
|||
assert snapshot["api_base"]
|
||||
|
||||
|
||||
class _GatedSyncLoggingHookRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.seen: Final = queue.SimpleQueue[str | None]()
|
||||
self.release: Final = threading.Event()
|
||||
|
||||
def logging_hook(
|
||||
self, kwargs: dict[str, object], result: object, call_type: str
|
||||
) -> tuple[dict[str, object], object]:
|
||||
self.seen.put(result.id if isinstance(result, litellm.ModelResponse) else None)
|
||||
self.release.wait(timeout=5)
|
||||
return kwargs, result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_runs_a_custom_logger_sync_logging_hook_exactly_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def legacy_sync_callback(
|
||||
kwargs: dict[str, object], response: litellm.ModelResponse, start_time: datetime, end_time: datetime
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
recorder: Final = _GatedSyncLoggingHookRecorder()
|
||||
monkeypatch.setattr(litellm, "success_callback", [legacy_sync_callback, recorder])
|
||||
logging_futures: Final = queue.SimpleQueue[Future[object]]()
|
||||
real_submit: Final = logging_executor.submit
|
||||
|
||||
def submit_and_track(fn: Callable[..., object], *args: object, **kwargs: object) -> Future[object]:
|
||||
future: Final = real_submit(fn, *args, **kwargs)
|
||||
logging_futures.put(future)
|
||||
return future
|
||||
|
||||
with patch( # test-quality-ok: wraps the real submit only to collect the futures to join, the pool still runs
|
||||
"litellm.litellm_core_utils.litellm_logging.executor.submit", side_effect=submit_and_track
|
||||
):
|
||||
response: Final = await litellm.acompletion(
|
||||
model="gpt-5.5",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
mock_response="Hello there!",
|
||||
num_retries=0,
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
recorder.release.set()
|
||||
for _ in range(logging_futures.qsize()):
|
||||
logging_futures.get_nowait().result(timeout=5)
|
||||
assert [recorder.seen.get_nowait() for _ in range(recorder.seen.qsize())] == [response.id]
|
||||
|
||||
|
||||
def test_completion_finishes_response_metadata_before_handing_the_response_to_the_logging_thread():
|
||||
with _recording_hidden_params_at_submit("litellm.utils.executor.submit") as seen:
|
||||
litellm.completion(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
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", passwordResetRequired: mockPasswordResetRequired }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
fromError: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
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 } });
|
||||
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();
|
||||
mockPasswordResetRequired = false;
|
||||
});
|
||||
|
||||
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(<ChangePasswordForm />);
|
||||
|
||||
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(<ChangePasswordForm />);
|
||||
|
||||
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(<ChangePasswordForm />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
describe("forced password reset", () => {
|
||||
it("shows the forced-reset warning only when the session is flagged", () => {
|
||||
mockPasswordResetRequired = true;
|
||||
render(<ChangePasswordForm />);
|
||||
|
||||
expect(screen.getByText(/must be changed before you can use the dashboard/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the forced-reset warning for a normal session", () => {
|
||||
render(<ChangePasswordForm />);
|
||||
|
||||
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(<ChangePasswordForm />);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
"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, 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({
|
||||
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<typeof changePasswordSchema>;
|
||||
|
||||
export function ChangePasswordForm() {
|
||||
const { accessToken, passwordResetRequired } = useAuthorized();
|
||||
const form = useZodForm(changePasswordSchema, {
|
||||
defaultValues: { currentPassword: "", newPassword: "", confirmNewPassword: "" },
|
||||
});
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (values: ChangePasswordValues) => {
|
||||
if (!accessToken) return;
|
||||
setSubmitError(null);
|
||||
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) {
|
||||
setSubmitError(extractProxyErrorMessage(error));
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-10 w-full max-w-md">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<h3 className="text-2xl font-semibold text-foreground">Change Password</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your current password and choose a new one. The new password must meet this proxy's password
|
||||
policy.
|
||||
</p>
|
||||
|
||||
{passwordResetRequired && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<CircleAlert />
|
||||
<AlertTitle>
|
||||
Your password must be changed before you can use the dashboard: it was either found in a known data
|
||||
breach or set by an administrator as a temporary password. After updating it, you will be signed out to
|
||||
log in again.
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form className="mb-2 mt-8" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="currentPassword" label="Current Password">
|
||||
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="current-password" />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="newPassword" label="New Password">
|
||||
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="new-password" />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="confirmNewPassword" label="Confirm New Password">
|
||||
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="new-password" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="error" className="mt-6">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{submitError}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending && <UiLoadingSpinner className="size-4" role="img" aria-label="loading" />}
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangePasswordForm;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import ChangePasswordForm from "./ChangePasswordForm";
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
return <ChangePasswordForm />;
|
||||
}
|
||||
|
|
@ -50,7 +50,9 @@ 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",
|
||||
passwordResetRequired: decoded?.password_reset_required === true,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import Layout from "./layout";
|
||||
|
|
@ -117,4 +117,60 @@ describe("(dashboard) Layout", () => {
|
|||
expect(screen.queryByTestId("dashboard-header")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("sidebar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("forced password reset routing", () => {
|
||||
const sessionCookie = (claims: Record<string, unknown>) => {
|
||||
const encode = (part: Record<string, unknown>) =>
|
||||
btoa(JSON.stringify(part)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ ...claims, exp })}.sig`;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.cookie = "token=; Max-Age=0; Path=/";
|
||||
});
|
||||
|
||||
it("routes a session flagged password_reset_required to the change-password page", async () => {
|
||||
const flaggedClaims = {
|
||||
user_id: "flagged-user",
|
||||
key: "sk-session",
|
||||
login_method: "username_password",
|
||||
password_reset_required: true,
|
||||
};
|
||||
document.cookie = `token=${sessionCookie(flaggedClaims)}; Path=/`;
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<div data-testid="page-content" />
|
||||
</Layout>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
pendingUiConfig.resolve();
|
||||
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/change-password")));
|
||||
});
|
||||
|
||||
it("does not reroute an unflagged session", async () => {
|
||||
document.cookie = `token=${sessionCookie({
|
||||
user_id: "normal-user",
|
||||
key: "sk-session",
|
||||
login_method: "username_password",
|
||||
})}; Path=/`;
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<div data-testid="page-content" />
|
||||
</Layout>
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
pendingUiConfig.resolve();
|
||||
|
||||
expect(await screen.findByTestId("page-content")).toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen";
|
|||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
|
||||
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
|
||||
import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner";
|
||||
|
|
@ -146,7 +146,8 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { accessToken, authLoading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const { accessToken, authLoading, passwordResetRequired } = useAuth();
|
||||
const isInvitationFlow = Boolean(searchParams.get("invitation_id"));
|
||||
|
||||
// Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own
|
||||
|
|
@ -157,6 +158,14 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
}, [authLoading, isInvitationFlow, router, searchParams]);
|
||||
|
||||
// A session flagged for a forced password reset can only reach the change-password
|
||||
// endpoint server-side; keep the UI on the matching page.
|
||||
useEffect(() => {
|
||||
if (!authLoading && passwordResetRequired && !pathname?.endsWith("/change-password")) {
|
||||
router.replace(uiHref("change-password"));
|
||||
}
|
||||
}, [authLoading, passwordResetRequired, pathname, router]);
|
||||
|
||||
if (authLoading || isInvitationFlow) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -660,6 +660,12 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel.description && (
|
||||
<div className="col-span-2">
|
||||
<p className="font-medium">Description:</p>
|
||||
<p className="mt-1">{selectedModel.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface ModelHubData {
|
|||
supports_function_calling: boolean;
|
||||
supported_openai_params?: string[];
|
||||
is_public_model_group: boolean;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
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(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
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(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
|
|
|||
|
|
@ -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<UserDropdownProps> = ({ 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<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{variant === "sidebar" ? (
|
||||
<PopoverTrigger
|
||||
render={
|
||||
|
|
@ -258,6 +262,19 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
>
|
||||
{renderUserInfoSection()}
|
||||
<Separator />
|
||||
{loginMethod === "username_password" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface ModelGroupInfo {
|
|||
health_status?: string;
|
||||
health_response_time?: number;
|
||||
health_checked_at?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface AuthMock {
|
|||
userRoleLabel: string;
|
||||
premiumUser: boolean;
|
||||
accessToken: string;
|
||||
loginMethod?: string | null;
|
||||
}
|
||||
|
||||
let mockUseAuthorizedImpl: () => 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(<SidebarAccountMenu onLogout={mockOnLogout} />);
|
||||
|
||||
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(<SidebarAccountMenu onLogout={mockOnLogout} />);
|
||||
|
||||
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(<SidebarAccountMenu onLogout={mockOnLogout} />);
|
||||
|
|
|
|||
|
|
@ -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<SidebarAccountMenuProps> = ({ 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<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
|
||||
|
|
@ -235,6 +239,20 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
|
||||
<Separator />
|
||||
|
||||
{loginMethod === "username_password" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<KeyRound className="size-[19px] text-muted-foreground" />
|
||||
Change Password
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onLogout}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -1677,6 +1677,20 @@ export const claimOnboardingToken = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const changePasswordCall = async (
|
||||
accessToken: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<{ user_id: string; message: string }> => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -714,6 +714,12 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
{selectedModel.description && (
|
||||
<div className="col-span-2">
|
||||
<p className="font-medium">Description:</p>
|
||||
<p className="mt-1">{selectedModel.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wildcard Routing Note */}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type AuthContextValue = {
|
|||
premiumUser: boolean;
|
||||
disabledPersonalKeyCreation: boolean;
|
||||
showSSOBanner: boolean;
|
||||
passwordResetRequired: boolean;
|
||||
|
||||
setToken: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setUserID: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
|
|
@ -46,6 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const [premiumUser, setPremiumUser] = useState(false);
|
||||
const [disabledPersonalKeyCreation, setDisabledPersonalKeyCreation] = useState(false);
|
||||
const [showSSOBanner, setShowSSOBanner] = useState(true);
|
||||
const [passwordResetRequired, setPasswordResetRequired] = useState(false);
|
||||
|
||||
// Load runtime UI config (populates proxyBaseUrl etc.) before clearing
|
||||
// authLoading, so any consumer that builds proxy-rooted URLs from authLoading=false
|
||||
|
|
@ -124,6 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
if (decoded.user_id) {
|
||||
setUserID(decoded.user_id);
|
||||
}
|
||||
setPasswordResetRequired(decoded.password_reset_required === true);
|
||||
}, [token]);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
|
|
@ -136,6 +139,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
premiumUser,
|
||||
disabledPersonalKeyCreation,
|
||||
showSSOBanner,
|
||||
passwordResetRequired,
|
||||
setToken,
|
||||
setUserID,
|
||||
setUserRole,
|
||||
|
|
|
|||
98
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
98
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -8938,7 +8938,7 @@ export interface paths {
|
|||
* `model_info.direct_access` when the proxy database is connected.
|
||||
*
|
||||
* Returns:
|
||||
* Returns a dictionary containing information about each model.
|
||||
* A JSON response whose `data` list holds one entry per model.
|
||||
*
|
||||
* Example Response:
|
||||
* ```json
|
||||
|
|
@ -16781,7 +16781,6 @@ export interface paths {
|
|||
* - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking.
|
||||
* - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" }
|
||||
* - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x.
|
||||
* - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests.
|
||||
* - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys)
|
||||
* - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
|
||||
* - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
|
||||
|
|
@ -16798,6 +16797,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.
|
||||
|
|
@ -16820,6 +16820,36 @@ 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). A successful change lifts any pending forced
|
||||
* password reset (`password_reset_required`) on the account.
|
||||
*
|
||||
* 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;
|
||||
|
|
@ -16867,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. 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.
|
||||
|
|
@ -16887,7 +16917,6 @@ export interface paths {
|
|||
* - permissions: Optional[dict] - [Not Implemented Yet] User-specific permissions, eg. turning off pii masking.
|
||||
* - metadata: Optional[dict] - Metadata for user, store information for user. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" }
|
||||
* - max_parallel_requests: Optional[int] - Rate limit a user based on the number of parallel requests. Raises 429 error, if user's parallel requests > x.
|
||||
* - soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests.
|
||||
* - model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys)
|
||||
* - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
|
||||
* - model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
|
||||
|
|
@ -19086,7 +19115,7 @@ export interface paths {
|
|||
* `model_info.direct_access` when the proxy database is connected.
|
||||
*
|
||||
* Returns:
|
||||
* Returns a dictionary containing information about each model.
|
||||
* A JSON response whose `data` list holds one entry per model.
|
||||
*
|
||||
* Example Response:
|
||||
* ```json
|
||||
|
|
@ -24905,6 +24934,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: {
|
||||
/**
|
||||
|
|
@ -30307,6 +30350,8 @@ export interface components {
|
|||
budget_reset_at?: string | null;
|
||||
/** Created At */
|
||||
created_at?: string | null;
|
||||
/** Last Breach Check At */
|
||||
last_breach_check_at?: string | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -30341,6 +30386,8 @@ export interface components {
|
|||
organization_id?: string | null;
|
||||
/** Organization Memberships */
|
||||
organization_memberships?: components["schemas"]["LiteLLM_OrganizationMembershipTable"][] | null;
|
||||
/** Password Reset Required */
|
||||
password_reset_required?: boolean | null;
|
||||
/**
|
||||
* Policies
|
||||
* @default []
|
||||
|
|
@ -30400,6 +30447,8 @@ export interface components {
|
|||
* @default 0
|
||||
*/
|
||||
key_count: number;
|
||||
/** Last Breach Check At */
|
||||
last_breach_check_at?: string | null;
|
||||
/** Max Budget */
|
||||
max_budget?: number | null;
|
||||
/** Max Parallel Requests */
|
||||
|
|
@ -30434,6 +30483,8 @@ export interface components {
|
|||
organization_id?: string | null;
|
||||
/** Organization Memberships */
|
||||
organization_memberships?: components["schemas"]["LiteLLM_OrganizationMembershipTable"][] | null;
|
||||
/** Password Reset Required */
|
||||
password_reset_required?: boolean | null;
|
||||
/**
|
||||
* Policies
|
||||
* @default []
|
||||
|
|
@ -32081,6 +32132,8 @@ export interface components {
|
|||
ModelGroupInfoProxy: {
|
||||
/** Configurable Clientside Auth Params */
|
||||
configurable_clientside_auth_params?: (string | components["schemas"]["ConfigurableClientsideParamsCustomAuth-Output"])[] | null;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/** Health Checked At */
|
||||
health_checked_at?: string | null;
|
||||
/** Health Response Time */
|
||||
|
|
@ -32869,6 +32922,8 @@ export interface components {
|
|||
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
|
||||
/** Organizations */
|
||||
organizations?: string[] | null;
|
||||
/** Password */
|
||||
password?: string | null;
|
||||
/**
|
||||
* Permissions
|
||||
* @default {}
|
||||
|
|
@ -60952,6 +61007,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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue