feat(auth): add disable_env_credential_login setting with admin ui warning

Env-credential login (UI_USERNAME/UI_PASSWORD, or the master key when
UI_PASSWORD is unset) is always live today. This adds a general_settings
flag to turn that login path off once real admin accounts exist, and a
warning banner shown to any admin while it remains enabled.

The banner flag is served through /health/readiness/details and stays
quiet when disable_password_login_when_sso_enabled already makes the env
path unreachable.
This commit is contained in:
Oliver Jensen 2026-09-07 14:09:37 +02:00
parent 168a0055a2
commit 8576cf74c2
11 changed files with 364 additions and 5 deletions

View file

@ -2817,6 +2817,18 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"UI username/password login. Default is False."
),
)
disable_env_credential_login: bool | None = Field(
None,
description=(
"If True, disables signing in to the Admin UI with the environment credentials: "
"UI_USERNAME/UI_PASSWORD, or the master key when UI_PASSWORD is unset (that fallback "
"means env-credential login is always live by default). Database users with passwords "
"are unaffected. LOCKOUT RISK: create at least one proxy admin user with a password "
"before enabling, or nobody can sign in to the UI. A locked-out admin can still "
"administer the proxy over the API with the master key, and can unset this setting "
"and restart the proxy to restore env-credential login. Default is False."
),
)
disable_budget_reservation: bool | None = Field(
None,
description=(

View file

@ -85,6 +85,29 @@ def get_ui_credentials(master_key: str | None) -> tuple[str, str]:
return ui_username, ui_password
def _matches_env_credentials(username: str, password: str, master_key: str | None) -> bool:
ui_username, ui_password = get_ui_credentials(master_key)
return secrets.compare_digest(username.encode("utf-8"), ui_username.encode("utf-8")) and secrets.compare_digest(
password.encode("utf-8"), ui_password.encode("utf-8")
)
def is_env_credential_login_enabled(general_settings: Mapping[str, object]) -> bool:
"""Whether a login with UI_USERNAME/UI_PASSWORD (or the master-key fallback) can succeed.
Two settings can turn it off: `disable_env_credential_login` unconditionally, and
`disable_password_login_when_sso_enabled` as a side effect, since its gate rejects
every username/password login before the env comparison runs. Feeds both the
`authenticate_user` gate and the Admin UI warning banner, so the banner never nags
about a login path that is already unreachable.
"""
if general_settings.get("disable_env_credential_login") is True:
return False
if general_settings.get("disable_password_login_when_sso_enabled") is True and is_sso_provider_fully_configured():
return False
return True
class LoginResult:
"""Result object containing authentication data from login."""
@ -129,7 +152,8 @@ async def authenticate_user(
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`
`disable_password_login_when_sso_enabled` and
`disable_env_credential_login`
Returns:
LoginResult: Object containing authentication data
@ -170,8 +194,6 @@ async def authenticate_user(
code=500,
)
ui_username, ui_password = get_ui_credentials(master_key)
# Check if we can find the `username` in the db. On the UI, users can enter username=their email
_user_row: LiteLLM_UserTable | None = None
user_role: (
@ -197,8 +219,8 @@ async def authenticate_user(
- Login with UI_USERNAME and UI_PASSWORD
- Login with Invite Link `user_email` and `password` combination
"""
if secrets.compare_digest(username.encode("utf-8"), ui_username.encode("utf-8")) and secrets.compare_digest(
password.encode("utf-8"), ui_password.encode("utf-8")
if general_settings.get("disable_env_credential_login") is not True and _matches_env_credentials(
username, password, master_key
):
# Non SSO -> If user is using UI_USERNAME and UI_PASSWORD they are Proxy admin
user_role = LitellmUserRoles.PROXY_ADMIN

View file

@ -1582,6 +1582,23 @@ async def _show_no_redis_warning() -> bool:
return await count_live_proxy_workers(prisma_client) != 1
def _show_env_credential_login_warning() -> bool:
"""
Whether the UI should warn admins that env-credential login is still enabled.
UI_USERNAME/UI_PASSWORD (or the master key, when UI_PASSWORD is unset) grant
proxy-admin access with a shared static secret: no per-person identity, no
audit trail, no password policy, and it stays valid until the env var or
master key rotates. That is fine for first-time setup, so it is on by
default, but once real admin accounts exist it should be turned off with
`general_settings.disable_env_credential_login`.
"""
from litellm.proxy.auth.login_utils import is_env_credential_login_enabled
from litellm.proxy.proxy_server import general_settings
return is_env_credential_login_enabled(general_settings)
async def _get_health_readiness_details(
response: Response | None = None,
) -> dict[str, Any]:
@ -1623,6 +1640,7 @@ async def _get_health_readiness_details(
log_level_name: Final = logging.getLevelName(verbose_logger.getEffectiveLevel())
is_detailed_debug: Final = verbose_logger.isEnabledFor(logging.DEBUG)
show_no_redis_warning: Final = await _show_no_redis_warning()
show_env_credential_login_warning: Final = _show_env_credential_login_warning()
# check DB
if prisma_client is not None: # if db passed in, check if it's connected
@ -1650,6 +1668,7 @@ async def _get_health_readiness_details(
"log_level": log_level_name,
"is_detailed_debug": is_detailed_debug,
"show_no_redis_warning": show_no_redis_warning,
"show_env_credential_login_warning": show_env_credential_login_warning,
}
else:
return {
@ -1662,6 +1681,7 @@ async def _get_health_readiness_details(
"log_level": log_level_name,
"is_detailed_debug": is_detailed_debug,
"show_no_redis_warning": show_no_redis_warning,
"show_env_credential_login_warning": show_env_credential_login_warning,
}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Service Unhealthy ({e})")

View file

@ -23,6 +23,7 @@ from litellm.proxy.auth.login_utils import (
LoginResult,
authenticate_user,
get_ui_credentials,
is_env_credential_login_enabled,
)
@ -799,3 +800,156 @@ class TestDisablePasswordLoginWhenSSOEnabled:
assert isinstance(result, LoginResult)
assert result.user_id == LITELLM_PROXY_ADMIN_NAME
class TestDisableEnvCredentialLogin:
"""`disable_env_credential_login` must reject a login with the env
credentials (UI_USERNAME/UI_PASSWORD, or the master-key fallback when
UI_PASSWORD is unset) while leaving database-user password logins
untouched, so admins with real accounts keep a way in."""
@pytest.mark.asyncio
async def test_rejects_correct_env_credentials_when_disabled(self):
master_key = "sk-1234"
ui_username = "admin"
ui_password = "env-only-password"
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": ui_password}):
with pytest.raises(ProxyException) as exc_info:
await authenticate_user(
username=ui_username,
password=ui_password,
master_key=master_key,
prisma_client=mock_prisma_client,
general_settings={"disable_env_credential_login": True},
)
assert exc_info.value.type == ProxyErrorTypes.auth_error
assert exc_info.value.code == "401"
@pytest.mark.asyncio
async def test_rejects_master_key_fallback_when_disabled(self):
"""With UI_PASSWORD unset, the master key IS the env password, so the
setting must reject it too or it protects nothing by default."""
master_key = "sk-1234"
mock_prisma_client = MagicMock()
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
with patch.dict(os.environ, {"UI_USERNAME": "admin"}, clear=True):
with pytest.raises(ProxyException) as exc_info:
await authenticate_user(
username="admin",
password=master_key,
master_key=master_key,
prisma_client=mock_prisma_client,
general_settings={"disable_env_credential_login": True},
)
assert exc_info.value.code == "401"
@pytest.mark.asyncio
async def test_db_user_login_still_works_when_disabled(self):
master_key = "sk-1234"
user_email = "admin@example.com"
password = "Str0ng!Passw0rd"
mock_user = LiteLLM_UserTable(
user_id="db-admin-1",
user_email=user_email,
password=hash_token(token=password),
user_role=LitellmUserRoles.PROXY_ADMIN,
)
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": "env-password",
"DATABASE_URL": "postgresql://test:test@localhost/test",
},
clear=True,
):
with ExitStack() as stack:
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": "db-user-token"},
)
)
result = await authenticate_user(
username=user_email,
password=password,
master_key=master_key,
prisma_client=mock_prisma_client,
general_settings={"disable_env_credential_login": True},
)
assert isinstance(result, LoginResult)
assert result.user_id == "db-admin-1"
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
@pytest.mark.asyncio
async def test_env_login_still_works_when_setting_absent(self):
"""Env-credential login is the bootstrap path on a fresh install and
must stay on by default."""
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_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
class TestIsEnvCredentialLoginEnabled:
"""Drives the Admin UI warning banner: it must be True exactly when a
login with the env credentials could actually succeed."""
def test_enabled_by_default(self):
assert is_env_credential_login_enabled({}) is True
def test_disabled_by_dedicated_setting(self):
assert is_env_credential_login_enabled({"disable_env_credential_login": True}) is False
def test_explicit_false_keeps_it_enabled(self):
assert is_env_credential_login_enabled({"disable_env_credential_login": False}) is True
def test_disabled_when_sso_gate_blocks_all_password_logins(self):
"""`disable_password_login_when_sso_enabled` with SSO configured
rejects every username/password login before the env comparison runs,
so the banner must not nag about an already-unreachable path."""
with ExitStack() as stack:
_patch_sso_configured(stack, configured=True)
assert is_env_credential_login_enabled({"disable_password_login_when_sso_enabled": True}) is False
def test_enabled_when_sso_gate_is_set_but_sso_not_configured(self):
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

View file

@ -1301,6 +1301,33 @@ def test_health_readiness_details_returns_diagnostic_fields(monkeypatch):
assert "cache" in response_data
@pytest.mark.parametrize(
"general_settings, expected_warning",
[
({}, True),
({"disable_env_credential_login": True}, False),
],
)
def test_health_readiness_details_reports_env_credential_login_warning(monkeypatch, general_settings, expected_warning):
"""
The Admin UI banner is driven by this flag: it must be True while
env-credential login is possible and False once
`disable_env_credential_login` turns that login path off.
"""
app = FastAPI()
app.include_router(_health_endpoints_module.router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
client = TestClient(app)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
response = client.get("/health/readiness/details")
assert response.status_code == 200, response.text
assert response.json()["show_env_credential_login_warning"] is expected_warning
def test_health_readiness_allows_explicit_legacy_public_details(monkeypatch):
"""
Operators can explicitly preserve the legacy public readiness payload.

View file

@ -14,6 +14,7 @@ export interface HealthReadinessDetailsResponse {
log_level?: string;
is_detailed_debug?: boolean;
show_no_redis_warning?: boolean;
show_env_credential_login_warning?: boolean;
}
const fetchHealthReadinessDetails = async (accessToken: string): Promise<HealthReadinessDetailsResponse> => {

View file

@ -29,6 +29,10 @@ vi.mock("@/components/NoRedisWarningBanner", () => ({
NoRedisWarningBanner: () => null,
}));
vi.mock("@/components/EnvCredentialLoginWarningBanner", () => ({
EnvCredentialLoginWarningBanner: () => null,
}));
vi.mock("@/components/LicenseExpiryBanner", () => ({
LicenseExpiryBanner: () => null,
}));

View file

@ -10,6 +10,7 @@ import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import { useRouter, useSearchParams } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner";
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { UserBanner } from "@/components/UserBanner";
import { uiHref } from "@/utils/uiHref";
@ -113,6 +114,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<Navbar accessToken={accessToken} isPublicPage={false} />
<DebugWarningBanner accessToken={accessToken} />
<NoRedisWarningBanner accessToken={accessToken} />
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<main className="flex min-h-0 flex-1 overflow-hidden">
@ -132,6 +134,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<DashboardHeader />
<DebugWarningBanner accessToken={accessToken} />
<NoRedisWarningBanner accessToken={accessToken} />
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>

View file

@ -0,0 +1,76 @@
import { renderWithProviders, screen } from "../../tests/test-utils";
import { vi } from "vitest";
import { EnvCredentialLoginWarningBanner } from "./EnvCredentialLoginWarningBanner";
import type { HealthReadinessDetailsResponse } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import type { UseQueryResult } from "@tanstack/react-query";
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: vi.fn(),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: vi.fn(),
}));
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useAuth } from "@/contexts/AuthContext";
const mockDetails = (data: Partial<HealthReadinessDetailsResponse> | undefined) => {
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data } as UseQueryResult<HealthReadinessDetailsResponse>);
};
const mockRole = (userRole: string) => {
vi.mocked(useAuth).mockReturnValue({ userRole } as ReturnType<typeof useAuth>);
};
describe("EnvCredentialLoginWarningBanner", () => {
it("should warn an admin when env-credential login is enabled", () => {
mockRole("Admin");
mockDetails({ status: "healthy", show_env_credential_login_warning: true });
renderWithProviders(<EnvCredentialLoginWarningBanner accessToken="token" />);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText("Environment-credential login is enabled")).toBeInTheDocument();
});
it("should tell the admin to create a regular admin account before disabling", () => {
mockRole("Admin");
mockDetails({ status: "healthy", show_env_credential_login_warning: true });
renderWithProviders(<EnvCredentialLoginWarningBanner accessToken="token" />);
expect(screen.getByText(/First create a regular admin account/i)).toBeInTheDocument();
expect(screen.getByText("general_settings.disable_env_credential_login: true")).toBeInTheDocument();
});
it("should warn an admin viewer too", () => {
mockRole("Admin Viewer");
mockDetails({ status: "healthy", show_env_credential_login_warning: true });
renderWithProviders(<EnvCredentialLoginWarningBanner accessToken="token" />);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
it("should render nothing for a non-admin even when the proxy reports the warning", () => {
mockRole("Internal User");
mockDetails({ status: "healthy", show_env_credential_login_warning: true });
const { container } = renderWithProviders(<EnvCredentialLoginWarningBanner accessToken="token" />);
expect(container).toBeEmptyDOMElement();
});
it("should render nothing when env-credential login is disabled", () => {
mockRole("Admin");
mockDetails({ status: "healthy", show_env_credential_login_warning: false });
const { container } = renderWithProviders(<EnvCredentialLoginWarningBanner accessToken="token" />);
expect(container).toBeEmptyDOMElement();
});
it("should render nothing when readiness details are unavailable", () => {
mockRole("Admin");
mockDetails(undefined);
const { container } = renderWithProviders(<EnvCredentialLoginWarningBanner accessToken={null} />);
expect(container).toBeEmptyDOMElement();
});
it("should pass the access token to the readiness hook", () => {
mockRole("Admin");
mockDetails(undefined);
renderWithProviders(<EnvCredentialLoginWarningBanner accessToken="my-token" />);
expect(useHealthReadinessDetails).toHaveBeenCalledWith("my-token");
});
});

View file

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { TriangleAlert } from "lucide-react";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useAuth } from "@/contexts/AuthContext";
import { isAdminRole } from "@/utils/roles";
export const EnvCredentialLoginWarningBanner: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
const { userRole } = useAuth();
const { data: healthData } = useHealthReadinessDetails(accessToken);
if (!isAdminRole(userRole) || !healthData?.show_env_credential_login_warning) {
return null;
}
return (
<div
role="alert"
className="flex items-start gap-3 border-b border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<TriangleAlert className="mt-0.5 size-5 shrink-0" aria-hidden="true" />
<div>
<p className="font-semibold">Environment-credential login is enabled</p>
<p>
Anyone with <code className="font-mono">UI_USERNAME</code>/<code className="font-mono">UI_PASSWORD</code> (or
the master key, when <code className="font-mono">UI_PASSWORD</code> is unset) can sign in as a proxy admin
with a shared static secret. First create a regular admin account with its own password, then set{" "}
<code className="font-mono">general_settings.disable_env_credential_login: true</code> to turn this login path
off.
</p>
</div>
</div>
);
};

View file

@ -25778,6 +25778,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 Env Credential Login
* @description If True, disables signing in to the Admin UI with the environment credentials: UI_USERNAME/UI_PASSWORD, or the master key when UI_PASSWORD is unset (that fallback means env-credential login is always live by default). Database users with passwords are unaffected. LOCKOUT RISK: create at least one proxy admin user with a password before enabling, or nobody can sign in to the UI. A locked-out admin can still administer the proxy over the API with the master key, and can unset this setting and restart the proxy to restore env-credential login. Default is False.
*/
disable_env_credential_login?: 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.