mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
feat(auth): enforce configurable password policy and SSO-only login (#39381)
Adds a configurable password-strength policy (default: min 12 chars, upper/lower/number/special, all individually toggleable, floored at 8 so a misconfigured minimum cannot disable the length check, and unicode-aware so an accented letter cannot satisfy the special- character requirement) enforced on every path that sets a local user's password: /user/update, /user/bulk_update, and the invitation onboarding claim flow. Adds general_settings.disable_password_login_when_sso_enabled, which rejects username/password login on /login, /v2/login and /v3/login (including the UI_USERNAME/UI_PASSWORD admin fallback) once ANY configured SSO provider is FULLY ready: every companion secret/ endpoint an OAuth provider needs, checked independently per provider so a stray leftover client id for an unused provider can't mask a different, fully configured one; and for SAML, the optional python3-saml runtime being importable, checked without letting a fully-missing package's ModuleNotFoundError take down password login itself. SSO becomes the enforced boundary for interactive UI access without an incomplete, mixed, or half-installed SSO setup locking every admin out or breaking login outright. Master-key API access is untouched, and unsetting the setting plus a restart restores password login as the documented recovery path.
This commit is contained in:
parent
646f3404a5
commit
25991fe78a
19 changed files with 866 additions and 53 deletions
|
|
@ -39,9 +39,9 @@ async def available_enterprise_users(
|
|||
|
||||
if not premium_user:
|
||||
# check if SSO is enabled - show 5 user limit
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.auth_utils import has_user_setup_sso
|
||||
|
||||
if _has_user_setup_sso():
|
||||
if has_user_setup_sso():
|
||||
premium_user_data = EnterpriseLicenseData(
|
||||
max_users=5,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2704,6 +2704,40 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
None,
|
||||
description="List of MCP server fields that must be filled in for a submission to pass standards checks (e.g. ['description', 'source_url', 'alias']).",
|
||||
)
|
||||
password_policy_min_length: int | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Minimum length required for a locally-managed user's password. Default is 12; "
|
||||
"a value below 8 is floored to 8 rather than weakening the requirement further."
|
||||
),
|
||||
)
|
||||
password_policy_require_uppercase: bool | None = Field(
|
||||
None,
|
||||
description="If True (default), a locally-managed user's password must contain an uppercase letter.",
|
||||
)
|
||||
password_policy_require_lowercase: bool | None = Field(
|
||||
None,
|
||||
description="If True (default), a locally-managed user's password must contain a lowercase letter.",
|
||||
)
|
||||
password_policy_require_numbers: bool | None = Field(
|
||||
None,
|
||||
description="If True (default), a locally-managed user's password must contain a number.",
|
||||
)
|
||||
password_policy_require_special_characters: bool | None = Field(
|
||||
None,
|
||||
description="If True (default), a locally-managed user's password must contain a special (non-alphanumeric) character.",
|
||||
)
|
||||
disable_password_login_when_sso_enabled: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"If True and SSO is configured (MICROSOFT_CLIENT_ID, GOOGLE_CLIENT_ID, "
|
||||
"GENERIC_CLIENT_ID, or SAML_IDP_METADATA_URL/XML), disables username/password "
|
||||
"login on /login, /v2/login, and /v3/login so SSO is the only way to reach the "
|
||||
"Admin UI. An admin locked out of the UI can still administer the proxy over the "
|
||||
"API with the master key; unset this setting and restart the proxy to restore "
|
||||
"UI username/password login. Default is False."
|
||||
),
|
||||
)
|
||||
disable_budget_reservation: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
|
@ -1402,7 +1403,7 @@ def is_pass_through_provider_route(route: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _has_user_setup_sso() -> bool:
|
||||
def has_user_setup_sso() -> bool:
|
||||
"""
|
||||
Check if the user has set up single sign-on (SSO).
|
||||
|
||||
|
|
@ -1425,6 +1426,63 @@ def _has_user_setup_sso() -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _is_google_ready() -> bool:
|
||||
return bool(os.getenv("GOOGLE_CLIENT_ID")) and bool(os.getenv("GOOGLE_CLIENT_SECRET"))
|
||||
|
||||
|
||||
def _is_microsoft_ready() -> bool:
|
||||
return (
|
||||
bool(os.getenv("MICROSOFT_CLIENT_ID"))
|
||||
and bool(os.getenv("MICROSOFT_CLIENT_SECRET"))
|
||||
and bool(os.getenv("MICROSOFT_TENANT"))
|
||||
)
|
||||
|
||||
|
||||
def _is_generic_oauth_ready() -> bool:
|
||||
return (
|
||||
bool(os.getenv("GENERIC_CLIENT_ID"))
|
||||
and bool(os.getenv("GENERIC_CLIENT_SECRET"))
|
||||
and bool(os.getenv("GENERIC_AUTHORIZATION_ENDPOINT"))
|
||||
and bool(os.getenv("GENERIC_TOKEN_ENDPOINT"))
|
||||
and bool(os.getenv("GENERIC_USERINFO_ENDPOINT"))
|
||||
)
|
||||
|
||||
|
||||
def _is_saml_ready() -> bool:
|
||||
if not (os.getenv("SAML_IDP_METADATA_URL") or os.getenv("SAML_IDP_METADATA_XML")):
|
||||
return False
|
||||
# SAML's runtime (python3-saml) is an optional dependency; the SAML
|
||||
# handler itself fails closed on every request when it is missing
|
||||
# (SAMLAuthHandler raises before touching the IdP), so metadata alone
|
||||
# is not "ready" either. find_spec raises ModuleNotFoundError (rather
|
||||
# than returning None) when the top-level package is absent entirely,
|
||||
# so this must not be a bare boolean expression or every password
|
||||
# login would 500 on a deployment that configured SAML metadata
|
||||
# without installing the optional extra.
|
||||
try:
|
||||
return importlib.util.find_spec("onelogin.saml2.auth") is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def is_sso_provider_fully_configured() -> bool:
|
||||
"""Whether ANY configured SSO provider has every companion setting it
|
||||
needs to actually authenticate a user, not merely a client id.
|
||||
|
||||
A lone ``MICROSOFT_CLIENT_ID`` with no secret or tenant makes
|
||||
``has_user_setup_sso()`` return True while every real sign-in attempt
|
||||
fails, so a gate that BLOCKS the password fallback (unlike the UI
|
||||
discovery use of ``has_user_setup_sso()``, where a dead login button is
|
||||
merely confusing) must check readiness here, or it can lock every admin
|
||||
out with no way to sign in at all. Checks every provider independently
|
||||
(mirroring ``/sso/readiness``'s per-provider requirements) rather than
|
||||
stopping at the first one with a client id set, so a stray leftover
|
||||
client id for an unused provider can never mask a different, fully
|
||||
configured provider that would otherwise satisfy this gate.
|
||||
"""
|
||||
return _is_google_ready() or _is_microsoft_ready() or _is_generic_oauth_ready() or _is_saml_ready()
|
||||
|
||||
|
||||
def get_customer_user_header_from_mapping(user_id_mapping) -> list | None:
|
||||
"""Return the header_name mapped to CUSTOMER role, if any (dict-based)."""
|
||||
if not user_id_mapping:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ login endpoints (e.g., /login and /v2/login).
|
|||
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, cast
|
||||
|
||||
import jwt
|
||||
|
|
@ -24,6 +26,7 @@ from litellm.proxy._types import (
|
|||
UpdateUserRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
generate_key_helper_fn,
|
||||
|
|
@ -111,6 +114,7 @@ async def authenticate_user(
|
|||
password: str,
|
||||
master_key: str | None,
|
||||
prisma_client: PrismaClient | None,
|
||||
general_settings: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> LoginResult:
|
||||
"""
|
||||
Authenticate a user and generate an API key for UI access.
|
||||
|
|
@ -124,13 +128,40 @@ async def authenticate_user(
|
|||
password: Password from the login form
|
||||
master_key: Master key for the proxy (required)
|
||||
prisma_client: Prisma database client (optional)
|
||||
general_settings: Proxy general_settings, checked for
|
||||
`disable_password_login_when_sso_enabled`
|
||||
|
||||
Returns:
|
||||
LoginResult: Object containing authentication data
|
||||
|
||||
Raises:
|
||||
ProxyException: If authentication fails or required configuration is missing
|
||||
ProxyException: If authentication fails or required configuration is missing,
|
||||
or if username/password login is disabled while SSO is configured
|
||||
|
||||
Recovery: an admin locked out of the UI by
|
||||
`disable_password_login_when_sso_enabled` can still administer the proxy over
|
||||
the API with the master key (Authorization: Bearer <master_key>), which never
|
||||
goes through this function. To restore UI username/password login, unset the
|
||||
setting in config.yaml (or the DB-persisted general_settings) and restart the
|
||||
proxy; this is a deliberate, auditable config change rather than a hidden
|
||||
bypass.
|
||||
|
||||
The gate below requires the SSO provider to be FULLY configured (every
|
||||
companion secret/endpoint an actual sign-in needs), not merely that a
|
||||
client id is present, so an incomplete SSO setup can never disable the
|
||||
only working login path.
|
||||
"""
|
||||
if general_settings.get("disable_password_login_when_sso_enabled") is True and is_sso_provider_fully_configured():
|
||||
raise ProxyException(
|
||||
message=(
|
||||
"Username/password login is disabled because SSO is configured "
|
||||
"and 'disable_password_login_when_sso_enabled' is set. Sign in via SSO."
|
||||
),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="disable_password_login_when_sso_enabled",
|
||||
code=403,
|
||||
)
|
||||
|
||||
if master_key is None:
|
||||
raise ProxyException(
|
||||
message="Master Key not set for Proxy. Please set Master Key to use Admin UI. Set `LITELLM_MASTER_KEY` in .env or set general_settings:master_key in config.yaml. https://docs.litellm.ai/docs/proxy/virtual_keys. If set, use `--detailed_debug` to debug issue.",
|
||||
|
|
|
|||
92
litellm/proxy/auth/password_policy.py
Normal file
92
litellm/proxy/auth/password_policy.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Password-strength policy enforcement for locally-managed proxy users.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
|
||||
DEFAULT_MIN_LENGTH: Final = 12
|
||||
MIN_ALLOWED_LENGTH: Final = 8
|
||||
|
||||
|
||||
def _has_uppercase(password: str) -> bool:
|
||||
return any(ch.isupper() for ch in password)
|
||||
|
||||
|
||||
def _has_lowercase(password: str) -> bool:
|
||||
return any(ch.islower() for ch in password)
|
||||
|
||||
|
||||
def _has_digit(password: str) -> bool:
|
||||
return any(ch.isdigit() for ch in password)
|
||||
|
||||
|
||||
def _has_special_character(password: str) -> bool:
|
||||
"""Unicode-aware: a letter or digit from ANY script counts as
|
||||
alphanumeric, not just ASCII, so an accented letter (e.g. the second
|
||||
character of "Passwörd1234") cannot be miscounted as the required
|
||||
special character the way an ASCII-only `[^A-Za-z0-9]` regex would."""
|
||||
return any(not ch.isalnum() for ch in password)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PasswordPolicy:
|
||||
min_length: int
|
||||
require_uppercase: bool
|
||||
require_lowercase: bool
|
||||
require_numbers: bool
|
||||
require_special_characters: bool
|
||||
|
||||
|
||||
def _configured_min_length(general_settings: Mapping[str, object]) -> int:
|
||||
"""The configured minimum, floored at MIN_ALLOWED_LENGTH so a nonpositive
|
||||
or too-low override (a typo, or `0`/`false` coercing through) cannot
|
||||
silently disable the length requirement rather than merely relaxing it."""
|
||||
min_length_setting: Final = general_settings.get("password_policy_min_length")
|
||||
if isinstance(min_length_setting, bool) or not isinstance(min_length_setting, (int, float)):
|
||||
return DEFAULT_MIN_LENGTH
|
||||
return max(MIN_ALLOWED_LENGTH, int(min_length_setting))
|
||||
|
||||
|
||||
def get_password_policy(general_settings: Mapping[str, object]) -> PasswordPolicy:
|
||||
return PasswordPolicy(
|
||||
min_length=_configured_min_length(general_settings),
|
||||
require_uppercase=general_settings.get("password_policy_require_uppercase", True) is not False,
|
||||
require_lowercase=general_settings.get("password_policy_require_lowercase", True) is not False,
|
||||
require_numbers=general_settings.get("password_policy_require_numbers", True) is not False,
|
||||
require_special_characters=(
|
||||
general_settings.get("password_policy_require_special_characters", True) is not False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _policy_violations(password: str, policy: PasswordPolicy) -> tuple[str, ...]:
|
||||
checks: Final = (
|
||||
(len(password) < policy.min_length, f"be at least {policy.min_length} characters long"),
|
||||
(policy.require_uppercase and not _has_uppercase(password), "include an uppercase letter"),
|
||||
(policy.require_lowercase and not _has_lowercase(password), "include a lowercase letter"),
|
||||
(policy.require_numbers and not _has_digit(password), "include a number"),
|
||||
(policy.require_special_characters and not _has_special_character(password), "include a special character"),
|
||||
)
|
||||
return tuple(message for failed, message in checks if failed)
|
||||
|
||||
|
||||
def validate_password_policy(password: str, general_settings: Mapping[str, object]) -> None:
|
||||
"""Raise ``ProxyException`` (400) if ``password`` fails the configured policy."""
|
||||
policy: Final = get_password_policy(general_settings)
|
||||
violations: Final = _policy_violations(password, policy)
|
||||
if not violations:
|
||||
return
|
||||
raise ProxyException(
|
||||
message="Password does not meet the required policy: must " + ", ".join(violations) + ".",
|
||||
type=ProxyErrorTypes.validation_error,
|
||||
param="password",
|
||||
code=400,
|
||||
)
|
||||
|
|
@ -14,7 +14,7 @@ router: Final = APIRouter()
|
|||
@router.get("/.well-known/litellm-ui-config", response_model=UiDiscoveryEndpoints)
|
||||
@router.get("/litellm/.well-known/litellm-ui-config", response_model=UiDiscoveryEndpoints) # if mounted at root path
|
||||
async def get_ui_config():
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.auth_utils import has_user_setup_sso
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
from litellm.proxy.utils import get_proxy_base_url, get_server_root_path
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ async def get_ui_config():
|
|||
or general_settings.get("hide_default_credentials_hint", False) is True
|
||||
)
|
||||
|
||||
sso_configured: Final = _has_user_setup_sso()
|
||||
sso_configured: Final = has_user_setup_sso()
|
||||
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm._uuid import uuid
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_checks import get_team_object, get_user_object
|
||||
from litellm.proxy.auth.password_policy import validate_password_policy
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
object_permission_cache_key,
|
||||
|
|
@ -154,9 +155,10 @@ def _team_membership_table(
|
|||
return team_membership_table
|
||||
|
||||
|
||||
def _hash_password_in_dict(data: dict) -> None:
|
||||
"""Hash password field in-place if present."""
|
||||
def _hash_password_in_dict(data: dict, general_settings: Mapping[str, object]) -> None:
|
||||
"""Validate and hash password field in-place if present."""
|
||||
if "password" in data and data["password"] is not None:
|
||||
validate_password_policy(data["password"], general_settings)
|
||||
data["password"] = hash_password(data["password"])
|
||||
|
||||
|
||||
|
|
@ -500,7 +502,7 @@ async def new_user(
|
|||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.proxy_server import _license_check, prisma_client
|
||||
from litellm.proxy.proxy_server import _license_check, general_settings, prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
|
||||
|
|
@ -548,7 +550,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)
|
||||
_hash_password_in_dict(data_json, general_settings)
|
||||
teams = data.teams
|
||||
if teams is None:
|
||||
teams = check_if_default_team_set()
|
||||
|
|
@ -1405,7 +1407,7 @@ async def _update_single_user_helper(
|
|||
|
||||
Returns the updated user data or raises an exception on failure.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
|
||||
from litellm.proxy.proxy_server import general_settings, litellm_proxy_admin_name, prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("Not connected to DB!")
|
||||
|
|
@ -1420,7 +1422,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)
|
||||
_hash_password_in_dict(non_default_values, general_settings)
|
||||
|
||||
existing_user_row: BaseModel | None = None
|
||||
if user_request.user_id:
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken, get_user_object
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
_get_request_ip_address,
|
||||
_has_user_setup_sso,
|
||||
has_user_setup_sso,
|
||||
)
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
|
@ -2617,7 +2617,7 @@ async def get_ui_settings(request: Request):
|
|||
_proxy_base_url: Final = os.getenv("PROXY_BASE_URL", None)
|
||||
_logout_url: Final = os.getenv("PROXY_LOGOUT_URL", None)
|
||||
_api_doc_base_url: Final = os.getenv("LITELLM_UI_API_DOC_BASE_URL", None)
|
||||
_is_sso_enabled: Final = _has_user_setup_sso()
|
||||
_is_sso_enabled: Final = has_user_setup_sso()
|
||||
disable_expensive_db_queries: Final = (
|
||||
proxy_state.get_proxy_state_variable("spend_logs_row_count") > MAX_SPENDLOG_ROWS_TO_QUERY
|
||||
)
|
||||
|
|
|
|||
|
|
@ -310,6 +310,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.user_api_key_auth import (
|
||||
_fetch_global_spend_with_event_coordination,
|
||||
user_api_key_auth,
|
||||
|
|
@ -15242,6 +15243,7 @@ async def login(request: Request):
|
|||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
# Create UI token object
|
||||
|
|
@ -15316,6 +15318,7 @@ async def login_v2(request: Request):
|
|||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
returned_ui_token_object: Final = create_ui_token_object(
|
||||
|
|
@ -15386,6 +15389,7 @@ async def login_v3(request: Request):
|
|||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
returned_ui_token_object: Final = create_ui_token_object(
|
||||
|
|
@ -15755,6 +15759,7 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
|
|||
detail={"error": "Invalid onboarding session for invitation link."},
|
||||
)
|
||||
|
||||
validate_password_policy(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:
|
||||
|
|
|
|||
|
|
@ -3169,7 +3169,7 @@ class TestIsRequestBodySafeChecksBracketNotationMetadata:
|
|||
|
||||
|
||||
class TestHasUserSetupSso:
|
||||
"""_has_user_setup_sso must treat SAML IdP metadata as SSO configured.
|
||||
"""has_user_setup_sso must treat SAML IdP metadata as SSO configured.
|
||||
|
||||
Regression: UI discovery used this helper for sso_configured, but it only
|
||||
checked OAuth client IDs, so SAML-only setups left the login button gray.
|
||||
|
|
@ -3187,29 +3187,167 @@ class TestHasUserSetupSso:
|
|||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
def test_false_when_no_sso_env(self):
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.auth_utils import has_user_setup_sso
|
||||
|
||||
assert _has_user_setup_sso() is False
|
||||
assert has_user_setup_sso() is False
|
||||
|
||||
def test_true_for_oauth_client_ids(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.auth_utils import has_user_setup_sso
|
||||
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-client")
|
||||
assert _has_user_setup_sso() is True
|
||||
assert has_user_setup_sso() is True
|
||||
|
||||
def test_true_for_saml_metadata_url(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.auth_utils import has_user_setup_sso
|
||||
|
||||
monkeypatch.setenv(
|
||||
"SAML_IDP_METADATA_URL", "https://idp.example.com/metadata.xml"
|
||||
)
|
||||
assert _has_user_setup_sso() is True
|
||||
assert has_user_setup_sso() is True
|
||||
|
||||
def test_true_for_saml_metadata_xml(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.auth_utils import has_user_setup_sso
|
||||
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_XML", "<EntityDescriptor/>")
|
||||
assert _has_user_setup_sso() is True
|
||||
assert has_user_setup_sso() is True
|
||||
|
||||
|
||||
class TestIsSsoProviderFullyConfigured:
|
||||
"""A lone client id must not read as ready: `has_user_setup_sso()` only
|
||||
checks the client id (correct for a UI-discovery "show the login button"
|
||||
decision), but a gate that BLOCKS the password fallback needs every
|
||||
companion setting the provider requires, or an incomplete setup locks
|
||||
every admin out with no working login path at all."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_sso_env(self, monkeypatch):
|
||||
for key in (
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"GOOGLE_CLIENT_SECRET",
|
||||
"MICROSOFT_CLIENT_ID",
|
||||
"MICROSOFT_CLIENT_SECRET",
|
||||
"MICROSOFT_TENANT",
|
||||
"GENERIC_CLIENT_ID",
|
||||
"GENERIC_CLIENT_SECRET",
|
||||
"GENERIC_AUTHORIZATION_ENDPOINT",
|
||||
"GENERIC_TOKEN_ENDPOINT",
|
||||
"GENERIC_USERINFO_ENDPOINT",
|
||||
"SAML_IDP_METADATA_URL",
|
||||
"SAML_IDP_METADATA_XML",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
def test_false_when_nothing_configured(self):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
assert is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_google_client_id_alone_is_not_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-client")
|
||||
assert is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_google_with_secret_is_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-client")
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "google-secret")
|
||||
assert is_sso_provider_fully_configured() is True
|
||||
|
||||
def test_microsoft_client_id_alone_is_not_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-client")
|
||||
assert is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_microsoft_missing_tenant_is_not_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-client")
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "ms-secret")
|
||||
assert is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_microsoft_with_secret_and_tenant_is_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-client")
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "ms-secret")
|
||||
monkeypatch.setenv("MICROSOFT_TENANT", "ms-tenant")
|
||||
assert is_sso_provider_fully_configured() is True
|
||||
|
||||
def test_generic_client_id_alone_is_not_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-client")
|
||||
assert is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_generic_missing_one_endpoint_is_not_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-client")
|
||||
monkeypatch.setenv("GENERIC_CLIENT_SECRET", "generic-secret")
|
||||
monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://idp.example.com/authorize")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
# GENERIC_USERINFO_ENDPOINT deliberately left unset.
|
||||
assert is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_generic_with_every_endpoint_is_ready(self, monkeypatch):
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-client")
|
||||
monkeypatch.setenv("GENERIC_CLIENT_SECRET", "generic-secret")
|
||||
monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://idp.example.com/authorize")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
monkeypatch.setenv("GENERIC_USERINFO_ENDPOINT", "https://idp.example.com/userinfo")
|
||||
assert is_sso_provider_fully_configured() is True
|
||||
|
||||
def test_saml_metadata_url_is_ready_when_runtime_installed(self, monkeypatch):
|
||||
from litellm.proxy.auth import auth_utils
|
||||
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata.xml")
|
||||
monkeypatch.setattr(auth_utils.importlib.util, "find_spec", lambda name: object())
|
||||
assert auth_utils.is_sso_provider_fully_configured() is True
|
||||
|
||||
def test_saml_metadata_url_is_not_ready_without_runtime(self, monkeypatch):
|
||||
"""Regression: python3-saml (``onelogin.saml2``) is an optional
|
||||
dependency; SAMLAuthHandler fails closed on every request when it is
|
||||
not installed, so IdP metadata alone must not read as ready."""
|
||||
from litellm.proxy.auth import auth_utils
|
||||
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata.xml")
|
||||
monkeypatch.setattr(auth_utils.importlib.util, "find_spec", lambda name: None)
|
||||
assert auth_utils.is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_saml_check_does_not_raise_when_package_entirely_absent(self, monkeypatch):
|
||||
"""Regression: `importlib.util.find_spec("onelogin.saml2.auth")`
|
||||
raises ModuleNotFoundError (not merely returns None) when the
|
||||
TOP-LEVEL `onelogin` package is not installed at all, which is
|
||||
exactly the real-world "optional extra not installed" case. If the
|
||||
gate does not catch this, every password login 500s instead of
|
||||
falling back, on a deployment that configured SAML metadata but
|
||||
skipped the extra."""
|
||||
from litellm.proxy.auth import auth_utils
|
||||
|
||||
def _raise(name: str):
|
||||
raise ModuleNotFoundError("No module named 'onelogin'")
|
||||
|
||||
monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata.xml")
|
||||
monkeypatch.setattr(auth_utils.importlib.util, "find_spec", _raise)
|
||||
assert auth_utils.is_sso_provider_fully_configured() is False
|
||||
|
||||
def test_incomplete_earlier_provider_does_not_mask_a_ready_later_one(self, monkeypatch):
|
||||
"""Regression: a stray GOOGLE_CLIENT_ID with no secret (e.g. a
|
||||
leftover from a migration) must not stop the check from reaching a
|
||||
fully configured Microsoft provider set alongside it — every
|
||||
provider is evaluated independently, not in a first-match order."""
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
|
||||
monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-client")
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-client")
|
||||
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "ms-secret")
|
||||
monkeypatch.setenv("MICROSOFT_TENANT", "ms-tenant")
|
||||
assert is_sso_provider_fully_configured() is True
|
||||
|
||||
|
||||
class TestIsRequestBodySafeBlocksAwsIdentitySelectors:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ to login_utils.py for better reusability.
|
|||
"""
|
||||
|
||||
import os
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -598,3 +599,203 @@ class TestEncodeUiSessionJwt:
|
|||
request.cookies = {"token": token}
|
||||
with patch("litellm.proxy.proxy_server.master_key", "sk-master-for-tests"):
|
||||
assert _user_id_from_session_cookie(request) == "cornell-user"
|
||||
|
||||
|
||||
def _patch_sso_configured(stack: ExitStack, *, configured: bool) -> None:
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: no HTTP boundary here; same internal the pre-existing tests above already mock
|
||||
"litellm.proxy.auth.login_utils.is_sso_provider_fully_configured", return_value=configured
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _patch_successful_admin_login_deps(stack: ExitStack) -> None:
|
||||
"""The collaborators a real admin login exercises past the SSO gate:
|
||||
generating the UI session key, syncing the admin role, and reading the
|
||||
experimental-login flag. Shared so the two "still allowed" tests below
|
||||
don't each repeat the same three-mock wiring."""
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: internal orchestration, no HTTP boundary; matches pre-existing tests
|
||||
"litellm.proxy.auth.login_utils.generate_key_helper_fn",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"token": "test-token", "user_id": LITELLM_PROXY_ADMIN_NAME},
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: internal orchestration, no HTTP boundary; matches pre-existing tests
|
||||
"litellm.proxy.auth.login_utils.user_update",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch( # test-quality-ok: internal orchestration, no HTTP boundary; matches pre-existing tests
|
||||
"litellm.proxy.auth.login_utils.get_secret_bool",
|
||||
return_value=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestDisablePasswordLoginWhenSSOEnabled:
|
||||
"""`disable_password_login_when_sso_enabled` must reject every
|
||||
username/password login attempt (including the UI_USERNAME/UI_PASSWORD
|
||||
admin fallback) once SSO is configured, so SSO becomes the only way to
|
||||
reach the Admin UI. It must not affect logins when SSO is unconfigured,
|
||||
so admins can never lock themselves out with no SSO to fall back to."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_correct_admin_credentials_when_sso_configured(self):
|
||||
master_key = "sk-1234"
|
||||
ui_username = "admin"
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
||||
|
||||
with patch.dict(os.environ, {"UI_USERNAME": ui_username, "UI_PASSWORD": master_key}):
|
||||
with ExitStack() as stack:
|
||||
_patch_sso_configured(stack, configured=True)
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await authenticate_user(
|
||||
username=ui_username,
|
||||
password=master_key,
|
||||
master_key=master_key,
|
||||
prisma_client=mock_prisma_client,
|
||||
general_settings={"disable_password_login_when_sso_enabled": True},
|
||||
)
|
||||
|
||||
assert exc_info.value.type == ProxyErrorTypes.auth_error
|
||||
assert exc_info.value.code == "403"
|
||||
# The credential comparison must never even run.
|
||||
mock_prisma_client.db.litellm_usertable.find_first.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_correct_db_user_credentials_when_sso_configured(self):
|
||||
master_key = "sk-1234"
|
||||
user_email = "test@example.com"
|
||||
password = "correct-password"
|
||||
|
||||
mock_user = LiteLLM_UserTable(
|
||||
user_id="test-user-123",
|
||||
user_email=user_email,
|
||||
password=hash_token(token=password),
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=mock_user)
|
||||
|
||||
with patch.dict(os.environ, {"UI_USERNAME": "admin", "UI_PASSWORD": "unrelated"}):
|
||||
with ExitStack() as stack:
|
||||
_patch_sso_configured(stack, configured=True)
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await authenticate_user(
|
||||
username=user_email,
|
||||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=mock_prisma_client,
|
||||
general_settings={"disable_password_login_when_sso_enabled": True},
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "403"
|
||||
mock_prisma_client.db.litellm_usertable.find_first.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_password_login_when_setting_enabled_but_sso_not_configured(self):
|
||||
"""The setting alone must not lock out an admin who has not actually
|
||||
configured SSO — there would be no fallback left."""
|
||||
master_key = "sk-1234"
|
||||
ui_username = "admin"
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"UI_USERNAME": ui_username,
|
||||
"UI_PASSWORD": master_key,
|
||||
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
_patch_sso_configured(stack, configured=False)
|
||||
_patch_successful_admin_login_deps(stack)
|
||||
result = await authenticate_user(
|
||||
username=ui_username,
|
||||
password=master_key,
|
||||
master_key=master_key,
|
||||
prisma_client=mock_prisma_client,
|
||||
general_settings={"disable_password_login_when_sso_enabled": True},
|
||||
)
|
||||
|
||||
assert isinstance(result, LoginResult)
|
||||
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_password_login_when_sso_env_is_incomplete(self):
|
||||
"""Regression: a lone MICROSOFT_CLIENT_ID with no client secret or
|
||||
tenant makes has_user_setup_sso() True, but a real SSO sign-in would
|
||||
fail. The gate must read the real env (no is_sso_provider_fully_configured
|
||||
mock here) and still let password login through, or an admin who set
|
||||
one env var by mistake is locked out with no way in."""
|
||||
master_key = "sk-1234"
|
||||
ui_username = "admin"
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"UI_USERNAME": ui_username,
|
||||
"UI_PASSWORD": master_key,
|
||||
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
||||
"MICROSOFT_CLIENT_ID": "ms-client-id-only",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
_patch_successful_admin_login_deps(stack)
|
||||
result = await authenticate_user(
|
||||
username=ui_username,
|
||||
password=master_key,
|
||||
master_key=master_key,
|
||||
prisma_client=mock_prisma_client,
|
||||
general_settings={"disable_password_login_when_sso_enabled": True},
|
||||
)
|
||||
|
||||
assert isinstance(result, LoginResult)
|
||||
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_password_login_when_sso_configured_but_setting_not_enabled(self):
|
||||
"""SSO being configured must not, by itself, disable the password
|
||||
fallback: the setting is opt-in."""
|
||||
master_key = "sk-1234"
|
||||
ui_username = "admin"
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"UI_USERNAME": ui_username,
|
||||
"UI_PASSWORD": master_key,
|
||||
"DATABASE_URL": "postgresql://test:test@localhost/test",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
with ExitStack() as stack:
|
||||
_patch_sso_configured(stack, configured=True)
|
||||
_patch_successful_admin_login_deps(stack)
|
||||
result = await authenticate_user(
|
||||
username=ui_username,
|
||||
password=master_key,
|
||||
master_key=master_key,
|
||||
prisma_client=mock_prisma_client,
|
||||
general_settings={},
|
||||
)
|
||||
|
||||
assert isinstance(result, LoginResult)
|
||||
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ async def test_claim_token_rejects_already_used_link():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
||||
|
|
@ -254,7 +254,7 @@ async def test_claim_token_rejects_expired_link():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
||||
|
|
@ -275,7 +275,7 @@ async def test_claim_token_rejects_mismatched_user_id():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="wrong-user",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
||||
|
|
@ -296,7 +296,7 @@ async def test_claim_token_rejects_missing_onboarding_token():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
|
||||
with (
|
||||
|
|
@ -322,7 +322,7 @@ async def test_claim_token_rejects_wrong_onboarding_session():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
request = _make_claim_request(
|
||||
_make_onboarding_token(invitation_link="other-invite")
|
||||
|
|
@ -351,7 +351,7 @@ async def test_claim_token_rejects_invalid_bearer_token():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
request = _make_claim_request("sk-regular-key")
|
||||
|
||||
|
|
@ -380,7 +380,7 @@ async def test_claim_token_rejects_concurrent_reuse_before_password_write():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
|
||||
with (
|
||||
|
|
@ -418,7 +418,7 @@ async def test_claim_token_sets_accepted_at_after_password_written():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
|
||||
mock_token_response = {"token": "sk-generated-key", "user_id": "user-123"}
|
||||
|
|
@ -477,7 +477,7 @@ async def test_claim_token_rolls_back_invite_when_session_key_mint_fails():
|
|||
data = InvitationClaim(
|
||||
invitation_link="invite-abc",
|
||||
user_id="user-123",
|
||||
password="NewP@ssw0rd",
|
||||
password="NewP@ssw0rd123",
|
||||
)
|
||||
|
||||
with (
|
||||
|
|
|
|||
136
tests/test_litellm/proxy/auth/test_password_policy.py
Normal file
136
tests/test_litellm/proxy/auth/test_password_policy.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
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_policy,
|
||||
)
|
||||
|
||||
STRONG_PASSWORD = "Str0ng!Passw0rd"
|
||||
|
||||
|
||||
def test_get_password_policy_defaults_to_pif_baseline():
|
||||
policy = get_password_policy({})
|
||||
assert policy == PasswordPolicy(
|
||||
min_length=DEFAULT_MIN_LENGTH,
|
||||
require_uppercase=True,
|
||||
require_lowercase=True,
|
||||
require_numbers=True,
|
||||
require_special_characters=True,
|
||||
)
|
||||
|
||||
|
||||
def test_get_password_policy_reads_overrides_from_general_settings():
|
||||
policy = get_password_policy(
|
||||
{
|
||||
"password_policy_min_length": 20,
|
||||
"password_policy_require_uppercase": False,
|
||||
"password_policy_require_lowercase": False,
|
||||
"password_policy_require_numbers": False,
|
||||
"password_policy_require_special_characters": False,
|
||||
}
|
||||
)
|
||||
assert policy == PasswordPolicy(
|
||||
min_length=20,
|
||||
require_uppercase=False,
|
||||
require_lowercase=False,
|
||||
require_numbers=False,
|
||||
require_special_characters=False,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_password_policy_accepts_strong_password():
|
||||
assert validate_password_policy(STRONG_PASSWORD, {}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"password,expected_fragment",
|
||||
[
|
||||
("Sh0rt!Pw", "12 characters"),
|
||||
("weakpassword123!", "uppercase"),
|
||||
("WEAKPASSWORD123!", "lowercase"),
|
||||
("WeakPassword!!!!", "number"),
|
||||
("WeakPassword12345", "special character"),
|
||||
],
|
||||
)
|
||||
def test_validate_password_policy_rejects_each_missing_class(password, expected_fragment):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
validate_password_policy(password, {})
|
||||
assert exc_info.value.code == "400"
|
||||
assert exc_info.value.type == ProxyErrorTypes.validation_error
|
||||
assert exc_info.value.param == "password"
|
||||
assert expected_fragment in exc_info.value.message
|
||||
|
||||
|
||||
def test_validate_password_policy_reports_every_violation_at_once():
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
validate_password_policy("weak", {})
|
||||
assert "12 characters" in exc_info.value.message
|
||||
assert "uppercase" in exc_info.value.message
|
||||
assert "number" in exc_info.value.message
|
||||
assert "special character" in exc_info.value.message
|
||||
|
||||
|
||||
def test_validate_password_policy_honors_relaxed_config():
|
||||
general_settings = {
|
||||
"password_policy_min_length": MIN_ALLOWED_LENGTH,
|
||||
"password_policy_require_special_characters": False,
|
||||
}
|
||||
# 8 chars, has upper/lower/number, no special char: fails default policy,
|
||||
# passes the relaxed one above.
|
||||
validate_password_policy("Abcd1234", general_settings)
|
||||
with pytest.raises(ProxyException):
|
||||
validate_password_policy("Abcd1234", {})
|
||||
|
||||
|
||||
def test_validate_password_policy_honors_stricter_min_length():
|
||||
general_settings = {"password_policy_min_length": 20}
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
validate_password_policy(STRONG_PASSWORD, general_settings)
|
||||
assert "20 characters" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_min_length", [0, -1, -100, 1, 7])
|
||||
def test_get_password_policy_floors_nonpositive_or_too_low_min_length(configured_min_length):
|
||||
"""A misconfigured min_length must never disable the length check
|
||||
entirely: it floors at MIN_ALLOWED_LENGTH instead of passing through."""
|
||||
policy = get_password_policy({"password_policy_min_length": configured_min_length})
|
||||
assert policy.min_length == MIN_ALLOWED_LENGTH
|
||||
|
||||
|
||||
def test_validate_password_policy_rejects_short_password_even_with_zero_min_length_configured():
|
||||
general_settings = {"password_policy_min_length": 0}
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
validate_password_policy("a", general_settings)
|
||||
assert f"{MIN_ALLOWED_LENGTH} characters" in exc_info.value.message
|
||||
|
||||
|
||||
def test_get_password_policy_ignores_boolean_min_length():
|
||||
"""`bool` is a subclass of `int` in Python; a stray `true`/`false` value
|
||||
must not silently coerce into a min_length of 1 or 0."""
|
||||
policy = get_password_policy({"password_policy_min_length": False})
|
||||
assert policy.min_length == DEFAULT_MIN_LENGTH
|
||||
|
||||
|
||||
def test_validate_password_policy_rejects_unicode_letter_as_special_character():
|
||||
"""Regression: an ASCII-only `[^A-Za-z0-9]` check would miscount an
|
||||
accented letter as the required special character, so a letters-and-
|
||||
digits-only password like this one (no real symbol) must still be
|
||||
rejected."""
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
validate_password_policy("Passwörd1234", {})
|
||||
assert "special character" in exc_info.value.message
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -18,7 +18,7 @@ def test_ui_discovery_endpoints_with_defaults():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
|
||||
):
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ def test_ui_discovery_endpoints_with_custom_server_root_path():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/litellm"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
|
||||
):
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ def test_ui_discovery_endpoints_with_proxy_base_url_when_set():
|
|||
"litellm.proxy.utils.get_proxy_base_url",
|
||||
return_value="https://proxy.example.com",
|
||||
),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
|
||||
):
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_enabled():
|
|||
"litellm.proxy.utils.get_proxy_base_url",
|
||||
return_value="https://proxy.example.com",
|
||||
),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=True),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true", "DISABLE_ADMIN_UI": "false"},
|
||||
|
|
@ -121,7 +121,7 @@ def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_not_set_de
|
|||
"litellm.proxy.utils.get_proxy_base_url",
|
||||
return_value="https://proxy.example.com",
|
||||
),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=True),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
|
||||
):
|
||||
# Ensure AUTO_REDIRECT_UI_LOGIN_TO_SSO is not set (simulate default)
|
||||
|
|
@ -148,7 +148,7 @@ def test_ui_discovery_endpoints_with_sso_configured_but_auto_redirect_disabled()
|
|||
"litellm.proxy.utils.get_proxy_base_url",
|
||||
return_value="https://proxy.example.com",
|
||||
),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=True),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "false", "DISABLE_ADMIN_UI": "false"},
|
||||
|
|
@ -174,7 +174,7 @@ def test_ui_discovery_endpoints_with_sso_not_configured_but_auto_redirect_enable
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true", "DISABLE_ADMIN_UI": "false"},
|
||||
|
|
@ -203,7 +203,7 @@ def test_ui_discovery_endpoints_both_routes_return_same_data():
|
|||
"litellm.proxy.utils.get_proxy_base_url",
|
||||
return_value="https://proxy.example.com",
|
||||
),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=True),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"AUTO_REDIRECT_UI_LOGIN_TO_SSO": "true", "DISABLE_ADMIN_UI": "false"},
|
||||
|
|
@ -228,7 +228,7 @@ def test_ui_discovery_endpoints_with_auto_redirect_via_general_settings():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"auto_redirect_ui_login_to_sso": True},
|
||||
|
|
@ -254,7 +254,7 @@ def test_ui_discovery_endpoints_with_auto_redirect_env_var_overrides_general_set
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=True),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"auto_redirect_ui_login_to_sso": False},
|
||||
|
|
@ -281,7 +281,7 @@ def test_ui_discovery_endpoints_with_admin_ui_disabled():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "true"}, clear=False),
|
||||
):
|
||||
|
||||
|
|
@ -311,7 +311,7 @@ def test_ui_discovery_endpoints_is_control_plane_true_when_workers_configured():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
|
||||
):
|
||||
|
|
@ -336,7 +336,7 @@ def test_ui_discovery_endpoints_hide_default_credentials_hint_default_false():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
|
||||
):
|
||||
os.environ.pop("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", None)
|
||||
|
|
@ -357,7 +357,7 @@ def test_ui_discovery_endpoints_hide_default_credentials_hint_via_env_var():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
|
|
@ -384,7 +384,7 @@ def test_ui_discovery_endpoints_hide_default_credentials_hint_via_general_settin
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"hide_default_credentials_hint": True},
|
||||
|
|
@ -411,7 +411,7 @@ def test_ui_discovery_endpoints_is_control_plane_false_when_no_workers():
|
|||
with (
|
||||
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
|
||||
patch("litellm.proxy.utils.get_proxy_base_url", return_value=None),
|
||||
patch("litellm.proxy.auth.auth_utils._has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
|
||||
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
|
||||
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -4220,3 +4220,88 @@ async def test_user_new_persists_model_max_budget(
|
|||
)
|
||||
|
||||
assert captured["user_data"].get("model_max_budget") == expected_written
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _admin_prisma(mocker):
|
||||
"""A mocked prisma_client wired in as proxy_server's module globals, for
|
||||
the password-policy tests below (mirrors the pattern every other test in
|
||||
this file repeats per-test; consolidated here since these three share it
|
||||
verbatim)."""
|
||||
mock_prisma_client = mocker.MagicMock()
|
||||
mocker.patch( # test-quality-ok: same module-global mocking every test in this file already uses
|
||||
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
|
||||
)
|
||||
mocker.patch( # test-quality-ok: same module-global mocking every test in this file already uses
|
||||
"litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"
|
||||
)
|
||||
return mock_prisma_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_update_rejects_weak_password(_admin_prisma):
|
||||
"""/user/update must reject a password that fails the configured
|
||||
policy before it ever reaches the DB write."""
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import (
|
||||
_update_single_user_helper,
|
||||
)
|
||||
|
||||
user_request = UpdateUserRequest(user_id="target-user", password="short1!")
|
||||
admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
|
||||
|
||||
assert exc_info.value.code == "400"
|
||||
_admin_prisma.db.litellm_usertable.find_first.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_update_rejects_weak_password_against_configured_policy(_admin_prisma, mocker):
|
||||
"""A password that meets the default policy but not a stricter
|
||||
admin-configured one must still be rejected."""
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import (
|
||||
_update_single_user_helper,
|
||||
)
|
||||
|
||||
mocker.patch( # test-quality-ok: same module-global mocking every test in this file already uses
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"password_policy_min_length": 24},
|
||||
)
|
||||
|
||||
user_request = UpdateUserRequest(user_id="target-user", password="Str0ng!Passw0rd")
|
||||
admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
|
||||
|
||||
assert "24 characters" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mocker):
|
||||
"""A password meeting the policy is hashed (never stored in plaintext)
|
||||
and reaches the DB write."""
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import (
|
||||
_update_single_user_helper,
|
||||
)
|
||||
|
||||
mock_prisma_client = _admin_prisma
|
||||
existing_user = mocker.MagicMock()
|
||||
existing_user.model_dump.return_value = {"user_id": "target-user"}
|
||||
existing_user.user_id = "target-user"
|
||||
mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=existing_user)
|
||||
mock_prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": "target-user"})
|
||||
mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
|
||||
|
||||
strong_password = "Str0ng!Passw0rd"
|
||||
user_request = UpdateUserRequest(user_id="target-user", password=strong_password)
|
||||
admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller)
|
||||
|
||||
written_data = mock_prisma_client.update_data.call_args.kwargs["data"]
|
||||
assert written_data.get("password") is not None
|
||||
assert written_data["password"] != strong_password
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ def _install_login_mocks(monkeypatch, raise_on_auth: bool = False) -> None:
|
|||
"""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
async def _fake_auth(username, password, master_key, prisma_client):
|
||||
async def _fake_auth(username, password, master_key, prisma_client, general_settings=None):
|
||||
if raise_on_auth:
|
||||
raise Exception("boom-auth-failure")
|
||||
fake = MagicMock()
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ def test_claim_onboarding_link_happy(client, monkeypatch, mock_prisma):
|
|||
json={
|
||||
"invitation_link": "inv-123",
|
||||
"user_id": "user-abc",
|
||||
"password": "hunter2",
|
||||
"password": "Hunter2Strong!",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {onboarding_jwt}"},
|
||||
)
|
||||
|
|
@ -260,7 +260,7 @@ def test_claim_onboarding_link_invalid_invite_401(client, monkeypatch, mock_pris
|
|||
json={
|
||||
"invitation_link": "missing",
|
||||
"user_id": "user-abc",
|
||||
"password": "hunter2",
|
||||
"password": "Hunter2Strong!",
|
||||
},
|
||||
headers={"Authorization": "Bearer irrelevant"},
|
||||
)
|
||||
|
|
@ -287,7 +287,7 @@ def test_claim_onboarding_link_user_id_mismatch_401(
|
|||
json={
|
||||
"invitation_link": "inv-123",
|
||||
"user_id": "user-attacker",
|
||||
"password": "hunter2",
|
||||
"password": "Hunter2Strong!",
|
||||
},
|
||||
headers={"Authorization": "Bearer irrelevant"},
|
||||
)
|
||||
|
|
@ -339,7 +339,7 @@ def test_claim_onboarding_link_bad_onboarding_jwt_401(
|
|||
json={
|
||||
"invitation_link": "inv-123",
|
||||
"user_id": "user-abc",
|
||||
"password": "hunter2",
|
||||
"password": "Hunter2Strong!",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {bogus_jwt}"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ def test_login_v2_returns_redirect_url_and_sets_cookie(monkeypatch):
|
|||
password="secret",
|
||||
master_key="test-master-key",
|
||||
prisma_client=mock_prisma_client,
|
||||
general_settings={},
|
||||
)
|
||||
mock_create_ui_token_object.assert_called_once_with(
|
||||
login_result=mock_login_result,
|
||||
|
|
|
|||
30
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
30
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25527,6 +25527,11 @@ export interface components {
|
|||
* @description If True, disables the optimistic per-request budget reservation introduced in v1.84.0. WARNING: This weakens hard budget enforcement. Without the reservation, a burst of concurrent requests from a single key can each pass the read-time spend check before any of them is charged, allowing a configured budget to be exceeded under high concurrency. Budgets are still evaluated on every request at read time, so an already-exhausted budget is still rejected. Enable only if your deployment is experiencing phantom BudgetExceededError responses caused by leaked reservations (see GitHub issue #27639). A proxy-level WARNING is logged on every request while this flag is active as a reminder that hard enforcement is relaxed.
|
||||
*/
|
||||
disable_budget_reservation?: boolean | null;
|
||||
/**
|
||||
* Disable Password Login When Sso Enabled
|
||||
* @description If True and SSO is configured (MICROSOFT_CLIENT_ID, GOOGLE_CLIENT_ID, GENERIC_CLIENT_ID, or SAML_IDP_METADATA_URL/XML), disables username/password login on /login, /v2/login, and /v3/login so SSO is the only way to reach the Admin UI. An admin locked out of the UI can still administer the proxy over the API with the master key; unset this setting and restart the proxy to restore UI username/password login. Default is False.
|
||||
*/
|
||||
disable_password_login_when_sso_enabled?: boolean | null;
|
||||
/**
|
||||
* Enable Public Model Hub
|
||||
* @description Public model hub for users to see what models they have access to, supported openai params, etc.
|
||||
|
|
@ -25677,6 +25682,31 @@ export interface components {
|
|||
* @description Default upstream request timeout in seconds for native and custom pass-through endpoints that use pass_through_request. Defaults to 600 when unset.
|
||||
*/
|
||||
pass_through_request_timeout?: number | null;
|
||||
/**
|
||||
* Password Policy Min Length
|
||||
* @description Minimum length required for a locally-managed user's password. Default is 12; a value below 8 is floored to 8 rather than weakening the requirement further.
|
||||
*/
|
||||
password_policy_min_length?: number | null;
|
||||
/**
|
||||
* Password Policy Require Lowercase
|
||||
* @description If True (default), a locally-managed user's password must contain a lowercase letter.
|
||||
*/
|
||||
password_policy_require_lowercase?: boolean | null;
|
||||
/**
|
||||
* Password Policy Require Numbers
|
||||
* @description If True (default), a locally-managed user's password must contain a number.
|
||||
*/
|
||||
password_policy_require_numbers?: boolean | null;
|
||||
/**
|
||||
* Password Policy Require Special Characters
|
||||
* @description If True (default), a locally-managed user's password must contain a special (non-alphanumeric) character.
|
||||
*/
|
||||
password_policy_require_special_characters?: boolean | null;
|
||||
/**
|
||||
* Password Policy Require Uppercase
|
||||
* @description If True (default), a locally-managed user's password must contain an uppercase letter.
|
||||
*/
|
||||
password_policy_require_uppercase?: boolean | null;
|
||||
/**
|
||||
* Plugins
|
||||
* @description external services registered as embeddable UI plugins
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue