[Feat] Email Notifications - Ensure Users get Key Rotated Email (#16292)
* add virtual_key_rotated * add KEY_ROTATED_EMAIL_TEMPLATE * fix base email * add _send_key_rotated_email * fix _send_key_rotated_email * key rotated email fix * fix: async_key_generated_hook ensure behavior matches regenerate * test_send_key_rotated_email * expose EMAIL_INCLUDE_API_KEY * docs mask api keys * add email regen * docs key regen * docs email * docs email * docs fix * fix code qa check
This commit is contained in:
parent
3fc262e92a
commit
2541a54023
@ -18,7 +18,7 @@ Send LiteLLM Proxy users emails for specific events.
|
||||
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| Supported Events | • User added as a user on LiteLLM Proxy<br/>• Proxy API Key created for user |
|
||||
| Supported Events | • User added as a user on LiteLLM Proxy<br/>• Proxy API Key created for user<br/>• Proxy API Key rotated for user |
|
||||
| Supported Email Integrations | • Resend API<br/>• 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.
|
||||
|
||||
<Image
|
||||
img={require('../../img/email_regen2.png')}
|
||||
style={{maxHeight: '600px', width: 'auto', display: 'block', margin: '0 0 2rem 0'}}
|
||||
/>
|
||||
|
||||
**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.
|
||||
|
||||
:::
|
||||
|
||||
<Image
|
||||
img={require('../../img/email_regen.png')}
|
||||
style={{width: '70%', display: 'block', margin: '0 0 2rem 0'}}
|
||||
/>
|
||||
|
||||
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 | `"<p>Best regards,<br/>Your Team</p><p><a href='https://your-company.com'>Visit us</a></p>"` | 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="<p>Best regards,<br/>Your Company Team</p><p><a href='https://y
|
||||
# Email Subject Lines
|
||||
EMAIL_SUBJECT_INVITATION="Welcome to Your Company!" # Subject for invitation emails
|
||||
EMAIL_SUBJECT_KEY_CREATED="Your API Key is Ready" # Subject for key creation emails
|
||||
EMAIL_SUBJECT_KEY_ROTATED="Your API Key Has Been Rotated" # Subject for key rotation emails
|
||||
|
||||
# Security Settings
|
||||
EMAIL_INCLUDE_API_KEY="false" # Set to false to hide API keys in emails (default: true)
|
||||
|
||||
# Proxy Configuration
|
||||
PROXY_BASE_URL="https://proxy.your-company.com" # Base URL for the LiteLLM Proxy (used in email links)
|
||||
```
|
||||
|
||||
## Security: Hiding API Keys in Emails
|
||||
|
||||
For enhanced security, you can configure LiteLLM to **not** include actual API keys in email notifications. This is useful when:
|
||||
|
||||
- You want to reduce the risk of key exposure via email interception
|
||||
- Your security policy requires keys to only be retrieved from the secure dashboard
|
||||
- You're concerned about email forwarding or storage security
|
||||
|
||||
When disabled, emails will show: `[Key hidden for security - retrieve from dashboard]` instead of the actual API key.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```bash
|
||||
# Hide API keys in emails (enhanced security)
|
||||
EMAIL_INCLUDE_API_KEY="false"
|
||||
|
||||
# Include API keys in emails (default behavior)
|
||||
EMAIL_INCLUDE_API_KEY="true" # or omit this variable
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
| Setting | Key Created Email | Key Rotated Email |
|
||||
|---------|------------------|-------------------|
|
||||
| `true` (default) | Shows actual `sk-xxxxx` key | Shows actual `sk-xxxxx` key |
|
||||
| `false` | Shows placeholder message | Shows placeholder message |
|
||||
|
||||
Users can always retrieve their keys from the LiteLLM Proxy dashboard.
|
||||
|
||||
## HTML Support in Email Signature
|
||||
|
||||
The `EMAIL_SIGNATURE` environment variable supports HTML formatting, allowing you to create rich, branded email footers. You can include:
|
||||
|
||||
BIN
docs/my-website/img/email_regen.png
Normal file
BIN
docs/my-website/img/email_regen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
BIN
docs/my-website/img/email_regen2.png
Normal file
BIN
docs/my-website/img/email_regen2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 784 KiB |
@ -173,7 +173,6 @@ const sidebars = {
|
||||
href: "https://litellm-api.up.railway.app/",
|
||||
},
|
||||
"proxy/enterprise",
|
||||
"proxy/management_cli",
|
||||
{
|
||||
type: "category",
|
||||
label: "Authentication",
|
||||
@ -185,19 +184,9 @@ const sidebars = {
|
||||
"proxy/cli_sso",
|
||||
"proxy/custom_auth",
|
||||
"proxy/ip_address",
|
||||
"proxy/email",
|
||||
"proxy/multiple_admins",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Spend Tracking",
|
||||
items: [
|
||||
"proxy/cost_tracking",
|
||||
"proxy/custom_pricing",
|
||||
"proxy/billing",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Budgets + Rate Limits",
|
||||
@ -221,6 +210,7 @@ const sidebars = {
|
||||
"proxy/rules",
|
||||
]
|
||||
},
|
||||
"proxy/management_cli",
|
||||
{
|
||||
type: "link",
|
||||
label: "Load Balancing, Routing, Fallbacks",
|
||||
@ -233,7 +223,8 @@ const sidebars = {
|
||||
"proxy/dynamic_logging",
|
||||
"proxy/logging",
|
||||
"proxy/logging_spec",
|
||||
"proxy/team_logging"
|
||||
"proxy/team_logging",
|
||||
"proxy/email",
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -271,6 +262,15 @@ const sidebars = {
|
||||
"oidc"
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Spend Tracking",
|
||||
items: [
|
||||
"proxy/cost_tracking",
|
||||
"proxy/custom_pricing",
|
||||
"proxy/billing",
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -11,6 +11,7 @@ from litellm_enterprise.types.enterprise_callbacks.send_emails import (
|
||||
EmailEvent,
|
||||
EmailParams,
|
||||
SendKeyCreatedEmailEvent,
|
||||
SendKeyRotatedEmailEvent,
|
||||
)
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
@ -19,10 +20,14 @@ from litellm.integrations.email_templates.email_footer import EMAIL_FOOTER
|
||||
from litellm.integrations.email_templates.key_created_email import (
|
||||
KEY_CREATED_EMAIL_TEMPLATE,
|
||||
)
|
||||
from litellm.integrations.email_templates.key_rotated_email import (
|
||||
KEY_ROTATED_EMAIL_TEMPLATE,
|
||||
)
|
||||
from litellm.integrations.email_templates.user_invitation_email import (
|
||||
USER_INVITATION_EMAIL_TEMPLATE,
|
||||
)
|
||||
from litellm.proxy._types import InvitationNew, UserAPIKeyAuth, WebhookEvent
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL
|
||||
|
||||
|
||||
@ -32,6 +37,7 @@ class BaseEmailLogger(CustomLogger):
|
||||
DEFAULT_SUBJECT_TEMPLATES = {
|
||||
EmailEvent.new_user_invitation: "LiteLLM: {event_message}",
|
||||
EmailEvent.virtual_key_created: "LiteLLM: {event_message}",
|
||||
EmailEvent.virtual_key_rotated: "LiteLLM: {event_message}",
|
||||
}
|
||||
|
||||
async def send_user_invitation_email(self, event: WebhookEvent):
|
||||
@ -83,11 +89,58 @@ class BaseEmailLogger(CustomLogger):
|
||||
f"send_key_created_email_event: {json.dumps(send_key_created_email_event, indent=4, default=str)}"
|
||||
)
|
||||
|
||||
# Check if API key should be included in email
|
||||
include_api_key = get_secret_bool(secret_name="EMAIL_INCLUDE_API_KEY", default_value=True)
|
||||
if include_api_key is None:
|
||||
include_api_key = True # Default to True if not set
|
||||
key_token_display = send_key_created_email_event.virtual_key if include_api_key else "[Key hidden for security - retrieve from dashboard]"
|
||||
|
||||
email_html_content = KEY_CREATED_EMAIL_TEMPLATE.format(
|
||||
email_logo_url=email_params.logo_url,
|
||||
recipient_email=email_params.recipient_email,
|
||||
key_budget=self._format_key_budget(send_key_created_email_event.max_budget),
|
||||
key_token=send_key_created_email_event.virtual_key,
|
||||
key_token=key_token_display,
|
||||
base_url=email_params.base_url,
|
||||
email_support_contact=email_params.support_contact,
|
||||
email_footer=email_params.signature,
|
||||
)
|
||||
|
||||
await self.send_email(
|
||||
from_email=self.DEFAULT_LITELLM_EMAIL,
|
||||
to_email=[email_params.recipient_email],
|
||||
subject=email_params.subject,
|
||||
html_body=email_html_content,
|
||||
)
|
||||
pass
|
||||
|
||||
async def send_key_rotated_email(
|
||||
self, send_key_rotated_email_event: SendKeyRotatedEmailEvent
|
||||
):
|
||||
"""
|
||||
Send email to user after rotating key for the user
|
||||
"""
|
||||
email_params = await self._get_email_params(
|
||||
user_id=send_key_rotated_email_event.user_id,
|
||||
user_email=send_key_rotated_email_event.user_email,
|
||||
email_event=EmailEvent.virtual_key_rotated,
|
||||
event_message=send_key_rotated_email_event.event_message,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f"send_key_rotated_email_event: {json.dumps(send_key_rotated_email_event, indent=4, default=str)}"
|
||||
)
|
||||
|
||||
# Check if API key should be included in email
|
||||
include_api_key = get_secret_bool(secret_name="EMAIL_INCLUDE_API_KEY", default_value=True)
|
||||
if include_api_key is None:
|
||||
include_api_key = True # Default to True if not set
|
||||
key_token_display = send_key_rotated_email_event.virtual_key if include_api_key else "[Key hidden for security - retrieve from dashboard]"
|
||||
|
||||
email_html_content = KEY_ROTATED_EMAIL_TEMPLATE.format(
|
||||
email_logo_url=email_params.logo_url,
|
||||
recipient_email=email_params.recipient_email,
|
||||
key_budget=self._format_key_budget(send_key_rotated_email_event.max_budget),
|
||||
key_token=key_token_display,
|
||||
base_url=email_params.base_url,
|
||||
email_support_contact=email_params.support_contact,
|
||||
email_footer=email_params.signature,
|
||||
@ -159,6 +212,13 @@ class BaseEmailLogger(CustomLogger):
|
||||
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_created],
|
||||
"key created subject template"
|
||||
)
|
||||
elif email_event == EmailEvent.virtual_key_rotated:
|
||||
custom_subject_key_rotated = os.getenv("EMAIL_SUBJECT_KEY_ROTATED", None)
|
||||
subject_template = get_custom_or_default(
|
||||
custom_subject_key_rotated,
|
||||
self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_rotated],
|
||||
"key rotated subject template"
|
||||
)
|
||||
else:
|
||||
subject_template = "LiteLLM: {event_message}"
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import enum
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from litellm.proxy._types import WebhookEvent
|
||||
|
||||
|
||||
class EmailParams(BaseModel):
|
||||
logo_url: str
|
||||
support_contact: str
|
||||
@ -22,9 +23,19 @@ class SendKeyCreatedEmailEvent(WebhookEvent):
|
||||
"""
|
||||
|
||||
|
||||
class SendKeyRotatedEmailEvent(WebhookEvent):
|
||||
virtual_key: str
|
||||
key_alias: Optional[str] = None
|
||||
"""
|
||||
The virtual key that was rotated
|
||||
this will be sk-123xxx, since we will be emailing this to the user to start using the new key
|
||||
"""
|
||||
|
||||
|
||||
class EmailEvent(str, enum.Enum):
|
||||
virtual_key_created = "Virtual Key Created"
|
||||
new_user_invitation = "New User Invitation"
|
||||
virtual_key_rotated = "Virtual Key Rotated"
|
||||
|
||||
class EmailEventSettings(BaseModel):
|
||||
event: EmailEvent
|
||||
@ -37,8 +48,9 @@ class DefaultEmailSettings(BaseModel):
|
||||
"""Default settings for email events"""
|
||||
settings: Dict[EmailEvent, bool] = Field(
|
||||
default_factory=lambda: {
|
||||
EmailEvent.virtual_key_created: False, # Off by default
|
||||
EmailEvent.virtual_key_created: True, # On by default
|
||||
EmailEvent.new_user_invitation: True, # On by default
|
||||
EmailEvent.virtual_key_rotated: True, # On by default
|
||||
}
|
||||
)
|
||||
def to_dict(self) -> Dict[str, bool]:
|
||||
|
||||
225
litellm/integrations/email_templates/key_rotated_email.py
Normal file
225
litellm/integrations/email_templates/key_rotated_email.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""
|
||||
Modern Email Templates for LiteLLM Email Service with professional styling
|
||||
"""
|
||||
|
||||
KEY_ROTATED_EMAIL_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Your API Key Has Been Rotated</title>
|
||||
<style>
|
||||
body, html {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #333333;
|
||||
background-color: #f8fafc;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 560px;
|
||||
margin: 20px auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.header {{
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}}
|
||||
.content {{
|
||||
padding: 32px 40px;
|
||||
}}
|
||||
.greeting {{
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
color: #333333;
|
||||
}}
|
||||
.message {{
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.key-container {{
|
||||
margin: 28px 0;
|
||||
}}
|
||||
.key-label {{
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #4b5563;
|
||||
}}
|
||||
.key {{
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #4338ca;
|
||||
}}
|
||||
h2 {{
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 36px;
|
||||
margin-bottom: 16px;
|
||||
color: #333333;
|
||||
}}
|
||||
.budget-info {{
|
||||
background-color: #f0fdf4;
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
margin: 24px 0;
|
||||
font-size: 14px;
|
||||
border: 1px solid #dcfce7;
|
||||
}}
|
||||
.code-block {{
|
||||
background-color: #f8fafc;
|
||||
color: #334155;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
line-height: 1.6;
|
||||
border: 1px solid #e2e8f0;
|
||||
}}
|
||||
.code-comment {{
|
||||
color: #64748b;
|
||||
}}
|
||||
.code-string {{
|
||||
color: #0369a1;
|
||||
}}
|
||||
.code-keyword {{
|
||||
color: #7e22ce;
|
||||
}}
|
||||
.btn {{
|
||||
display: inline-block;
|
||||
padding: 8px 20px;
|
||||
background-color: #6366f1;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}}
|
||||
.btn:hover {{
|
||||
background-color: #4f46e5;
|
||||
color: #ffffff !important;
|
||||
}}
|
||||
.separator {{
|
||||
height: 1px;
|
||||
background-color: #f1f5f9;
|
||||
margin: 40px 0 30px;
|
||||
}}
|
||||
.footer {{
|
||||
padding: 24px 40px 32px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
background-color: #f8fafc;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}}
|
||||
.social-links {{
|
||||
margin-top: 12px;
|
||||
}}
|
||||
.social-links a {{
|
||||
display: inline-block;
|
||||
margin: 0 8px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
}}
|
||||
@media only screen and (max-width: 620px) {{
|
||||
.container {{
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}}
|
||||
.content {{
|
||||
padding: 24px 20px;
|
||||
}}
|
||||
.footer {{
|
||||
padding: 20px;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{email_logo_url}" alt="LiteLLM Logo" style="height: 32px; width: auto;">
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
<p>Hi {recipient_email},</p>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<p><strong>Your LiteLLM API key has been rotated</strong> as part of our ongoing commitment to security best practices.</p>
|
||||
<p style="margin-top: 16px;">Your previous API key has been deactivated and will no longer work. Please update your applications with the new key below.</p>
|
||||
</div>
|
||||
|
||||
<div class="key-container">
|
||||
<div class="key-label">Your New API Key</div>
|
||||
<div class="key">{key_token}</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-info">
|
||||
<p style="margin: 0;"><strong>Monthly Budget:</strong> {key_budget}</p>
|
||||
</div>
|
||||
|
||||
<h2>Action Required</h2>
|
||||
<p>Update your applications and systems with the new API key. Here's an example:</p>
|
||||
|
||||
<div class="code-block">
|
||||
<span class="code-keyword">import</span> openai<br>
|
||||
<br>
|
||||
client = openai.OpenAI(<br>
|
||||
api_key=<span class="code-string">"{key_token}"</span>,<br>
|
||||
base_url=<span class="code-string">"{base_url}"</span><br>
|
||||
)<br>
|
||||
<br>
|
||||
response = client.chat.completions.create(<br>
|
||||
model=<span class="code-string">"gpt-3.5-turbo"</span>, <span class="code-comment"># model to send to the proxy</span><br>
|
||||
messages = [<br>
|
||||
{{<br>
|
||||
<span class="code-string">"role"</span>: <span class="code-string">"user"</span>,<br>
|
||||
<span class="code-string">"content"</span>: <span class="code-string">"this is a test request, write a short poem"</span><br>
|
||||
}}<br>
|
||||
]<br>
|
||||
)
|
||||
</div>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<h2>Security Best Practices</h2>
|
||||
<p style="margin-bottom: 12px;">To keep your API key secure:</p>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #333333;">
|
||||
<li style="margin-bottom: 8px;">Never share your API key publicly or commit it to version control</li>
|
||||
<li style="margin-bottom: 8px;">Store it securely using environment variables or secret management systems</li>
|
||||
<li style="margin-bottom: 8px;">Monitor your API usage regularly for any unusual activity</li>
|
||||
<li style="margin-bottom: 8px;">Rotate your keys periodically as a security best practice</li>
|
||||
</ul>
|
||||
|
||||
<a href="https://docs.litellm.ai/docs/proxy/user_keys" class="btn" style="color: #ffffff;">View Documentation</a>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<h2>Need Help?</h2>
|
||||
<p>If you have any questions or need assistance updating your systems, please contact us at {email_support_contact}.</p>
|
||||
</div>
|
||||
{email_footer}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@ -2364,6 +2364,7 @@ class WebhookEvent(CallInfo):
|
||||
"threshold_crossed",
|
||||
"projected_limit_exceeded",
|
||||
"key_created",
|
||||
"key_rotated",
|
||||
"internal_user_created",
|
||||
"spend_tracked",
|
||||
]
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user