diff --git a/docs/my-website/docs/proxy/email.md b/docs/my-website/docs/proxy/email.md index 1ee67e82308..da8fc57deea 100644 --- a/docs/my-website/docs/proxy/email.md +++ b/docs/my-website/docs/proxy/email.md @@ -18,7 +18,7 @@ Send LiteLLM Proxy users emails for specific events. | Category | Details | |----------|---------| -| Supported Events | • User added as a user on LiteLLM Proxy
• Proxy API Key created for user | +| Supported Events | • User added as a user on LiteLLM Proxy
• Proxy API Key created for user
• Proxy API Key rotated for user | | Supported Email Integrations | • Resend API
• SMTP | ## Usage @@ -123,6 +123,35 @@ On the Create Key Modal, Select Advanced Settings > Set Send Email to True. style={{width: '70%', display: 'block', margin: '0 0 2rem 0'}} /> +### 3. Proxy API Key Rotated for User + +This email is sent when you rotate an API key for a user on LiteLLM Proxy. + + + +**How to trigger this event** + +On the LiteLLM Proxy UI, go to Virtual Keys > Click on a key > Click "Regenerate Key" + +:::info + +Ensure there is a `user_id` attached to the key. This would have been set when creating the key. + +::: + + + +After regenerating the key, the user will receive an email notification with: +- Security-focused messaging about the rotation +- The new API key (or a placeholder if `EMAIL_INCLUDE_API_KEY=false`) +- Instructions to update their applications +- Security best practices ## Email Customization @@ -141,6 +170,8 @@ LiteLLM allows you to customize various aspects of your email notifications. Bel | Email Signature | `EMAIL_SIGNATURE` | string (HTML) | Standard LiteLLM footer | `"

Best regards,
Your Team

Visit us

"` | HTML-formatted footer for all emails | | Invitation Subject | `EMAIL_SUBJECT_INVITATION` | string | "LiteLLM: New User Invitation" | `"Welcome to Your Company!"` | Subject line for invitation emails | | Key Creation Subject | `EMAIL_SUBJECT_KEY_CREATED` | string | "LiteLLM: API Key Created" | `"Your New API Key is Ready"` | Subject line for key creation emails | +| Key Rotation Subject | `EMAIL_SUBJECT_KEY_ROTATED` | string | "LiteLLM: API Key Rotated" | `"Your API Key Has Been Rotated"` | Subject line for key rotation emails | +| Include API Key | `EMAIL_INCLUDE_API_KEY` | boolean | true | `"false"` | Whether to include the actual API key in emails (set to false for enhanced security) | | Proxy Base URL | `PROXY_BASE_URL` | string | http://0.0.0.0:4000 | `"https://proxy.your-company.com"` | Base URL for the LiteLLM Proxy (used in email links) | @@ -181,11 +212,44 @@ EMAIL_SIGNATURE="

Best regards,
Your Company Team

View Documentation + +
+ +

Need Help?

+

If you have any questions or need assistance updating your systems, please contact us at {email_support_contact}.

+
+ {email_footer} +
+ + +""" + diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 783728d5a9b..d739727ccab 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2364,6 +2364,7 @@ class WebhookEvent(CallInfo): "threshold_crossed", "projected_limit_exceeded", "key_created", + "key_rotated", "internal_user_created", "spend_tracked", ] diff --git a/litellm/proxy/hooks/key_management_event_hooks.py b/litellm/proxy/hooks/key_management_event_hooks.py index 49072fe841a..44be6bbe656 100644 --- a/litellm/proxy/hooks/key_management_event_hooks.py +++ b/litellm/proxy/hooks/key_management_event_hooks.py @@ -1,11 +1,11 @@ import asyncio import json -from litellm._uuid import uuid from datetime import datetime, timezone from typing import Any, List, Optional import litellm from litellm._logging import verbose_proxy_logger +from litellm._uuid import uuid from litellm.proxy._types import ( CommonProxyErrors, GenerateKeyRequest, @@ -45,10 +45,9 @@ class KeyManagementEventHooks: ) from litellm.proxy.proxy_server import litellm_proxy_admin_name - if data.send_invite_email is True: - await KeyManagementEventHooks._send_key_created_email( - response.model_dump(exclude_none=True) - ) + await KeyManagementEventHooks._send_key_created_email( + response.model_dump(exclude_none=True) + ) # Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True if litellm.store_audit_logs is True: @@ -144,6 +143,12 @@ class KeyManagementEventHooks: new_secret_value=response.key, ) + # send key rotated email if configured + await KeyManagementEventHooks._send_key_rotated_email( + response=response.model_dump(exclude_none=True), + existing_key_alias=existing_key_row.key_alias, + ) + # store the audit log if litellm.store_audit_logs is True and existing_key_row.token is not None: asyncio.create_task( @@ -385,3 +390,53 @@ class KeyManagementEventHooks: webhook_event=event, ) ) + + @staticmethod + async def _send_key_rotated_email(response: dict, existing_key_alias: Optional[str]): + try: + from litellm_enterprise.enterprise_callbacks.send_emails.base_email import ( + BaseEmailLogger, + ) + except ImportError: + raise Exception( + "Trying to use Email Hooks" + + CommonProxyErrors.missing_enterprise_package.value + ) + + try: + from litellm_enterprise.types.enterprise_callbacks.send_emails import ( + SendKeyRotatedEmailEvent, + ) + except ImportError: + raise Exception( + "Trying to use Email Hooks" + + CommonProxyErrors.missing_enterprise_package.value + ) + + event = SendKeyRotatedEmailEvent( + virtual_key=response.get("key", ""), + event="key_rotated", + event_group=Litellm_EntityType.KEY, + event_message="API Key Rotated", + token=response.get("token", ""), + spend=response.get("spend", 0.0), + max_budget=response.get("max_budget", 0.0), + user_id=response.get("user_id", None), + team_id=response.get("team_id", "Default Team"), + key_alias=response.get("key_alias", existing_key_alias), + ) + + ########################## + # v2 integration for emails + ########################## + initialized_email_loggers = ( + litellm.logging_callback_manager.get_custom_loggers_for_type( + callback_type=BaseEmailLogger + ) + ) + if len(initialized_email_loggers) > 0: + for email_logger in initialized_email_loggers: + if isinstance(email_logger, BaseEmailLogger): + await email_logger.send_key_rotated_email( + send_key_rotated_email_event=event, + ) diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 3b7a04a6473..089de860e6d 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -40,7 +40,7 @@ litellm_settings: store_audit_logs: true verbose: true log_level: "DEBUG" # Options: DEBUG, INFO, WARNING, ERROR - success_callback: ["s3_v2"] + callbacks: ["s3_v2", "smtp_email"] s3_callback_params: s3_endpoint_url: "https://localhost:443" # Replace with your Minio server URL and port s3_aws_access_key_id: "minioadmin" diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py index d9886bd7100..6b140d489cf 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py @@ -4,14 +4,18 @@ import sys import unittest.mock as mock from unittest.mock import patch -from enterprise.litellm_enterprise.enterprise_callbacks.send_emails.base_email import BaseEmailLogger import pytest from fastapi.testclient import TestClient +from enterprise.litellm_enterprise.enterprise_callbacks.send_emails.base_email import ( + BaseEmailLogger, +) + sys.path.insert(0, os.path.abspath("../../..")) from litellm_enterprise.types.enterprise_callbacks.send_emails import ( EmailEvent, SendKeyCreatedEmailEvent, + SendKeyRotatedEmailEvent, ) from litellm.integrations.email_templates.email_footer import EMAIL_FOOTER @@ -208,6 +212,113 @@ async def test_send_key_created_email_no_email( await base_email_logger.send_key_created_email(event) +@pytest.mark.asyncio +async def test_send_key_rotated_email( + base_email_logger, mock_send_email, mock_lookup_user_email +): + """ + Test that send_key_rotated_email sends an email with the correct parameters and content + """ + event = SendKeyRotatedEmailEvent( + user_id="test_user", + user_email="test@example.com", + virtual_key="sk-rotated-key-123", + key_alias="test-key-alias", + max_budget=200.0, + spend=50.0, + event_group=Litellm_EntityType.KEY, + event="key_rotated", + event_message="API Key Rotated", + ) + + with mock.patch.dict( + os.environ, + { + "EMAIL_LOGO_URL": "https://litellm-listing.s3.amazonaws.com/litellm_logo.png", + "EMAIL_SUPPORT_CONTACT": "support@berri.ai", + "PROXY_BASE_URL": "http://test.com", + }, + ): + await base_email_logger.send_key_rotated_email(event) + + mock_send_email.assert_called_once() + call_args = mock_send_email.call_args[1] + assert call_args["from_email"] == BaseEmailLogger.DEFAULT_LITELLM_EMAIL + assert call_args["to_email"] == ["test@example.com"] + assert call_args["subject"] == "LiteLLM: API Key Rotated" + assert "sk-rotated-key-123" in call_args["html_body"] + assert "$200.0" in call_args["html_body"] + assert "rotated" in call_args["html_body"].lower() + assert "Security Best Practices" in call_args["html_body"] + + +@pytest.mark.asyncio +async def test_send_key_created_email_without_key( + base_email_logger, mock_send_email, mock_lookup_user_email +): + """ + Test that send_key_created_email hides the API key when EMAIL_INCLUDE_API_KEY is false + """ + event = SendKeyCreatedEmailEvent( + user_id="test_user", + user_email="test@example.com", + virtual_key="sk-secret-key-456", + max_budget=100.0, + spend=0.0, + event_group=Litellm_EntityType.USER, + event="key_created", + event_message="Test Key Created", + ) + + with mock.patch.dict( + os.environ, + { + "EMAIL_INCLUDE_API_KEY": "false", + "PROXY_BASE_URL": "http://test.com", + }, + ): + await base_email_logger.send_key_created_email(event) + + mock_send_email.assert_called_once() + call_args = mock_send_email.call_args[1] + assert "sk-secret-key-456" not in call_args["html_body"] + assert "[Key hidden for security - retrieve from dashboard]" in call_args["html_body"] + + +@pytest.mark.asyncio +async def test_send_key_rotated_email_without_key( + base_email_logger, mock_send_email, mock_lookup_user_email +): + """ + Test that send_key_rotated_email hides the API key when EMAIL_INCLUDE_API_KEY is false + """ + event = SendKeyRotatedEmailEvent( + user_id="test_user", + user_email="test@example.com", + virtual_key="sk-secret-rotated-789", + key_alias="test-key-alias", + max_budget=200.0, + spend=50.0, + event_group=Litellm_EntityType.KEY, + event="key_rotated", + event_message="API Key Rotated", + ) + + with mock.patch.dict( + os.environ, + { + "EMAIL_INCLUDE_API_KEY": "false", + "PROXY_BASE_URL": "http://test.com", + }, + ): + await base_email_logger.send_key_rotated_email(event) + + mock_send_email.assert_called_once() + call_args = mock_send_email.call_args[1] + assert "sk-secret-rotated-789" not in call_args["html_body"] + assert "[Key hidden for security - retrieve from dashboard]" in call_args["html_body"] + + @pytest.mark.asyncio async def test_get_invitation_link(base_email_logger): # Mock prisma client and its response