diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index a1abed202a2..0087ac97610 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -184,7 +184,7 @@ from litellm.router_utils.fallback_event_handlers import ( fallbacks_disabled_for_request, get_fallback_model_group_for_lookup_groups, ) -from litellm.secret_managers.main import get_secret_bool +from litellm.secret_managers.main import get_secret_bool, get_secret_str, normalize_nonempty_secret_str from litellm.types.services import ServiceTypes try: @@ -2737,6 +2737,18 @@ async def user_api_key_auth_builder( if ( prisma_client is None ): # if both master key + user key submitted, and user key != master key, and no db connected, raise an error + if normalize_nonempty_secret_str(get_secret_str("DATABASE_URL")) is None: + # No DB configured: an auth error, so allow_requests_on_db_unavailable can't fail open + raise ProxyException( + message=( + "Authentication Error, Invalid proxy server token passed. Token does not match " + "the configured master key, and no database is configured to look up virtual keys. " + "Set DATABASE_URL to enable virtual keys." + ), + type=ProxyErrorTypes.auth_error, + code=status.HTTP_401_UNAUTHORIZED, + param=None, + ) raise ProxyException( message="No connected db.", type=ProxyErrorTypes.no_db_connection, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 689a5f38416..dbf6d17b150 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12073,7 +12073,7 @@ class ProxyStartupEvent: """ connected_client: PrismaClient | None = None try: - if database_url is None: + if database_url is None or not database_url.strip(): return None prisma_client = PrismaClient(database_url=database_url, proxy_logging_obj=proxy_logging_obj) diff --git a/tests/unit/proxy/auth/test_user_api_key_auth.py b/tests/unit/proxy/auth/test_user_api_key_auth.py index 3660019cc2c..a2823f71d76 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth.py @@ -476,9 +476,9 @@ def test_is_api_route_allowed(route, user_role, expected_result): @pytest.mark.asyncio -async def test_auth_not_connected_to_db(): +async def test_auth_not_connected_to_db(monkeypatch: pytest.MonkeyPatch): """ - ensure requests don't fail when `prisma_client` = None + ensure requests don't fail when `prisma_client` = None but a database is configured """ from fastapi import Request from starlette.datastructures import URL @@ -486,6 +486,7 @@ async def test_auth_not_connected_to_db(): from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.proxy_server import hash_token, user_api_key_cache + monkeypatch.setenv("DATABASE_URL", "postgresql://unreachable.invalid:5432/litellm") user_key = "sk-98765678" setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) @@ -506,6 +507,46 @@ async def test_auth_not_connected_to_db(): assert valid_token.token == "failed-to-connect-to-db" +@pytest.mark.asyncio +@pytest.mark.parametrize("allow_requests_on_db_unavailable", [True, False]) +@pytest.mark.parametrize("database_url", [None, "", " "]) +async def test_auth_no_db_configured_rejects_non_master_key( + allow_requests_on_db_unavailable: bool, database_url: str | None, monkeypatch: pytest.MonkeyPatch +): + """ + With no database configured (DATABASE_URL unset or empty), a key that isn't the master key is an + auth error (401), and allow_requests_on_db_unavailable must not fail open: there is no outage to ride out. + """ + from fastapi import Request + from starlette.datastructures import URL + + from litellm.proxy._types import ProxyErrorTypes, ProxyException + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.proxy_server import user_api_key_cache + + if database_url is None: + monkeypatch.delenv("DATABASE_URL", raising=False) + else: + monkeypatch.setenv("DATABASE_URL", database_url) + setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) + setattr(litellm.proxy.proxy_server, "master_key", MASTER_KEY) + setattr(litellm.proxy.proxy_server, "prisma_client", None) + setattr( + litellm.proxy.proxy_server, + "general_settings", + {"allow_requests_on_db_unavailable": allow_requests_on_db_unavailable}, + ) + + request = Request(scope={"type": "http", "method": "POST", "path": "/chat/completions", "headers": []}) + request._url = URL(url="/chat/completions") + + with pytest.raises(ProxyException) as exc_info: + await user_api_key_auth(request=request, api_key="Bearer sk-not-the-master-key") + + assert exc_info.value.type == ProxyErrorTypes.auth_error + assert exc_info.value.code == "401" + + def _assert_api_key_from_custom_header(headers, custom_header_name, expected_api_key): verbose_proxy_logger.setLevel(logging.DEBUG) request = MagicMock(spec=Request) diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 4cdd7fefd28..ef5631c5353 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -12502,10 +12502,11 @@ async def test_lens_oauth2_validates_identity_without_inference_user_field( [("/lens", False, True), ("/lens/traces", True, False), ("/lens-other", True, True)], ) async def test_lens_oauth2_dispatch_preserves_opt_in_premium_and_route_boundary( - route: str, enabled: bool, premium: bool + route: str, enabled: bool, premium: bool, monkeypatch: pytest.MonkeyPatch ) -> None: from starlette.requests import Request + monkeypatch.delenv("DATABASE_URL", raising=False) request: Final = Request({"type": "http", "method": "GET", "path": route, "headers": [], "query_string": b""}) with ( patch("litellm.proxy.proxy_server.general_settings", {"enable_oauth2_auth": enabled}), @@ -12522,8 +12523,8 @@ async def test_lens_oauth2_dispatch_preserves_opt_in_premium_and_route_boundary( assert error.value.code == "403" assert "premium" in error.value.message.lower() else: - assert error.value.code == "400" - assert "No connected db" in error.value.message + assert error.value.code == "401" + assert "no database is configured" in error.value.message @pytest.mark.asyncio diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 0dbf3451c3b..d61a24cfb7d 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -14105,6 +14105,25 @@ async def test_setup_prisma_client_retains_connected_client_when_startup_health_ assert result is mock_client +@pytest.mark.asyncio +@pytest.mark.parametrize("database_url", ["", " "]) +async def test_setup_prisma_client_treats_an_empty_database_url_as_unset(database_url): + """An empty DATABASE_URL (e.g. from ``${DATABASE_URL:-}``) can never connect, so no client is + created. Otherwise auth sees a configured-but-unreachable DB and allow_requests_on_db_unavailable + fails open for any key.""" + from litellm.proxy.proxy_server import ProxyStartupEvent + + mock_client = _mock_startup_prisma_client() + with patch.object(proxy_server_module, "PrismaClient", return_value=mock_client): + result = await ProxyStartupEvent._setup_prisma_client( + database_url=database_url, + proxy_logging_obj=MagicMock(), + user_api_key_cache=DualCache(), + ) + + assert result is None + + @pytest.mark.asyncio async def test_setup_prisma_client_arms_health_watchdog_before_startup_health_check( monkeypatch,