Merge pull request #24884 from BerriAI/worktree-velvety-seeking-treehouse

feat(key-rotation): add key_rotation_email field for rotation notifications
This commit is contained in:
ishaan-berri 2026-03-31 17:16:55 -07:00 • committed by GitHub
commit 438653df7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 99 additions and 11 deletions

View file

@ -596,14 +596,32 @@ curl 'http://0.0.0.0:4000/key/generate' \
}'
```
**Set a custom rotation notification email (useful for service accounts)**
By default, rotation emails go to the key owner. Use `key_rotation_email` to send notifications to a specific address instead — useful when the key is owned by a service account with no real inbox.
```bash
curl 'http://0.0.0.0:4000/key/generate' \
-H 'Authorization: Bearer <your-master-key>' \
-H 'Content-Type: application/json' \
-d '{
"models": ["gpt-4o"],
"auto_rotate": true,
"rotation_interval": "30d",
"key_rotation_email": "platform-alerts@yourcompany.com"
}'
```
**LiteLLM UI**
On the LiteLLM UI, Navigate to the Keys page and click on `Generate Key` > `Key Lifecycle` > `Enable Auto Rotation`
<Image
<Image
img={require('../../img/key_r.png')}
style={{width: '30%', display: 'block', margin: '0'}}
/>
When auto-rotation is enabled, a **Rotation Notification Email** field appears. Leave it empty to use the key owner's email, or enter a specific address (e.g. a team alias or alert inbox).
**Valid rotation_interval formats:**
- `"30s"` - 30 seconds
- `"30m"` - 30 minutes
@ -622,7 +640,8 @@ curl 'http://0.0.0.0:4000/key/update' \
-d '{
"key": "sk-existing-key",
"auto_rotate": true,
"rotation_interval": "90d"
"rotation_interval": "90d",
"key_rotation_email": "platform-alerts@yourcompany.com"
}'
```
@ -630,7 +649,7 @@ curl 'http://0.0.0.0:4000/key/update' \
On the LiteLLM UI, Navigate to the Keys page. Select the key you want to update and click on `Edit Settings` > `Auto-Rotation Settings`
<Image
<Image
img={require('../../img/key_u.png')}
style={{width: '30%', display: 'block', margin: '0'}}
/>

View file

@ -158,7 +158,8 @@ class BaseEmailLogger(CustomLogger):
"""
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,
user_email=send_key_rotated_email_event.key_rotation_email
or send_key_rotated_email_event.user_email,
email_event=EmailEvent.virtual_key_rotated,
event_message=send_key_rotated_email_event.event_message,
)

View file

@ -26,6 +26,7 @@ class SendKeyCreatedEmailEvent(WebhookEvent):
class SendKeyRotatedEmailEvent(WebhookEvent):
virtual_key: str
key_alias: Optional[str] = None
key_rotation_email: 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

View file

@ -0,0 +1,7 @@
-- Add key_rotation_email to LiteLLM_VerificationToken and LiteLLM_DeletedVerificationToken
-- AlterTable
ALTER TABLE "LiteLLM_VerificationToken"
ADD COLUMN IF NOT EXISTS "key_rotation_email" TEXT;
ALTER TABLE "LiteLLM_DeletedVerificationToken"
ADD COLUMN IF NOT EXISTS "key_rotation_email" TEXT;

View file

@ -955,6 +955,10 @@ class GenerateKeyRequest(KeyRequestBase):
default=None,
description="How often to rotate this key (e.g., '30d', '90d'). Required if auto_rotate=True",
)
key_rotation_email: Optional[str] = Field(
default=None,
description="Email to notify when key is rotated. Overrides key owner email. Useful for service accounts.",
)
organization_id: Optional[str] = None
project_id: Optional[str] = None
@ -1009,6 +1013,7 @@ class UpdateKeyRequest(KeyRequestBase):
temp_budget_expiry: Optional[datetime] = None
auto_rotate: Optional[bool] = None
rotation_interval: Optional[str] = None
key_rotation_email: Optional[str] = None
organization_id: Optional[str] = None
@model_validator(mode="after")
@ -2361,6 +2366,9 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
rotation_interval: Optional[str] = None # How often to rotate (e.g., "30d", "90d")
last_rotation_at: Optional[datetime] = None # When this key was last rotated
key_rotation_at: Optional[datetime] = None # When this key should next be rotated
key_rotation_email: Optional[str] = (
None # Override email for rotation notifications
)
router_settings: Optional[dict] = None
model_config = ConfigDict(protected_namespaces=())

View file

@ -178,6 +178,9 @@ class KeyManagementEventHooks:
await KeyManagementEventHooks._send_key_rotated_email(
response=response.model_dump(exclude_none=True),
existing_key_alias=existing_key_row.key_alias,
key_rotation_email=getattr(
existing_key_row, "key_rotation_email", None
),
)
except Exception as e:
verbose_proxy_logger.warning(f"Failed to send key rotated email: {e}")
@ -364,10 +367,10 @@ class KeyManagementEventHooks:
if key.key_alias is not None:
team_id = getattr(key, "team_id", None)
if team_id not in team_settings_cache:
team_settings_cache[
team_id
] = await KeyManagementEventHooks._get_secret_manager_optional_params(
team_id
team_settings_cache[team_id] = (
await KeyManagementEventHooks._get_secret_manager_optional_params(
team_id
)
)
optional_params = team_settings_cache[team_id]
await litellm.secret_manager_client.async_delete_secret(
@ -542,7 +545,9 @@ class KeyManagementEventHooks:
@staticmethod
async def _send_key_rotated_email(
response: dict, existing_key_alias: Optional[str]
response: dict,
existing_key_alias: Optional[str],
key_rotation_email: Optional[str] = None,
):
"""
Send key rotated email if email sending is enabled.
@ -589,6 +594,7 @@ class KeyManagementEventHooks:
user_id=response.get("user_id", None),
team_id=response.get("team_id", "Default Team"),
key_alias=response.get("key_alias", existing_key_alias),
key_rotation_email=key_rotation_email,
)
##########################

View file

@ -2798,6 +2798,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None,
auto_rotate: Optional[bool] = None,
rotation_interval: Optional[str] = None,
key_rotation_email: Optional[str] = None,
router_settings: Optional[dict] = None,
access_group_ids: Optional[list] = None,
):
@ -2928,6 +2929,9 @@ async def generate_key_helper_fn( # noqa: PLR0915
rotation_interval=rotation_interval,
)
if key_rotation_email is not None:
key_data["key_rotation_email"] = key_rotation_email
if (
get_secret("DISABLE_KEY_NAME", False) is True
): # allow user to disable storing abbreviated key name (shown in UI, to help figure out which key spent how much)

View file

@ -387,6 +387,7 @@ model LiteLLM_VerificationToken {
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
last_rotation_at DateTime? // When this key was last rotated
key_rotation_at DateTime? // When this key should next be rotated
key_rotation_email String? // Override email for rotation notifications (useful for service accounts)
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
litellm_project_table LiteLLM_ProjectTable? @relation(fields: [project_id], references: [project_id])
@ -481,7 +482,8 @@ model LiteLLM_DeletedVerificationToken {
rotation_interval String?
last_rotation_at DateTime?
key_rotation_at DateTime?
key_rotation_email String?
// Deletion metadata
deleted_at DateTime @default(now()) @map("deleted_at")
deleted_by String? @map("deleted_by") // User who deleted the key

View file

@ -1,6 +1,7 @@
{
"ignore": [],
"exclude": ["**/node_modules", "**/__pycache__", "litellm/types/utils.py", "litellm/proxy/_types.py"],
"extraPaths": ["enterprise"],
"reportMissingImports": false,
"reportPrivateImportUsage": false
}

View file

@ -11,6 +11,8 @@ interface KeyLifecycleSettingsProps {
onAutoRotationChange: (enabled: boolean) => void;
rotationInterval: string;
onRotationIntervalChange: (interval: string) => void;
keyRotationEmail?: string;
onKeyRotationEmailChange?: (email: string) => void;
isCreateMode?: boolean; // If true, shows "leave empty to never expire" instead of "-1 to never expire"
neverExpire?: boolean;
onNeverExpireChange?: (checked: boolean) => void;
@ -22,6 +24,8 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
onAutoRotationChange,
rotationInterval,
onRotationIntervalChange,
keyRotationEmail = "",
onKeyRotationEmailChange,
isCreateMode = false,
neverExpire = false,
onNeverExpireChange,
@ -169,9 +173,27 @@ const KeyLifecycleSettings: React.FC<KeyLifecycleSettingsProps> = ({
)}
</div>
{autoRotationEnabled && (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 flex items-center space-x-1">
<span>Rotation Notification Email</span>
<Tooltip title="Email to notify when this key is rotated. Leave empty to use the key owner's email. Useful for service accounts.">
<InfoCircleOutlined className="text-gray-400 cursor-help text-xs" />
</Tooltip>
</label>
<TextInput
name="key_rotation_email"
placeholder="alerts@yourteam.com (optional)"
className="w-full"
value={keyRotationEmail}
onValueChange={(val) => onKeyRotationEmailChange?.(val)}
/>
</div>
)}
{autoRotationEnabled && (
<div className="bg-blue-50 p-3 rounded-md text-sm text-blue-700">
When rotation occurs, you&apos;ll receive a notification with the new key. The old key will be deactivated
When rotation occurs, a notification with the new key will be sent to the configured email (or the key owner if none set). The old key will be deactivated
after a brief grace period.
</div>
)}

View file

@ -93,6 +93,7 @@ export interface KeyResponse {
access_group_ids?: string[];
auto_rotate?: boolean;
rotation_interval?: string;
key_rotation_email?: string;
last_rotation_at?: string;
key_rotation_at?: string;
next_rotation_at?: string;

View file

@ -200,6 +200,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({});
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(false);
const [rotationInterval, setRotationInterval] = useState<string>("30d");
const [keyRotationEmail, setKeyRotationEmail] = useState<string>("");
const [routerSettings, setRouterSettings] = useState<RouterSettingsAccordionValue | null>(null);
const [routerSettingsKey, setRouterSettingsKey] = useState<number>(0);
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
@ -424,6 +425,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
if (autoRotationEnabled) {
formValues.auto_rotate = true;
formValues.rotation_interval = rotationInterval;
if (keyRotationEmail.trim()) {
formValues.key_rotation_email = keyRotationEmail.trim();
}
}
// Handle duration field for key expiry - convert empty string to null
@ -1532,6 +1536,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
onAutoRotationChange={setAutoRotationEnabled}
rotationInterval={rotationInterval}
onRotationIntervalChange={setRotationInterval}
keyRotationEmail={keyRotationEmail}
onKeyRotationEmailChange={setKeyRotationEmail}
isCreateMode={true}
/>
</div>

View file

@ -101,6 +101,7 @@ export function KeyEditView({
const [selectedOrganizationId, setSelectedOrganizationId] = useState<string | null>(keyData.organization_id || null);
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(keyData.auto_rotate || false);
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
const [keyRotationEmail, setKeyRotationEmail] = useState<string>(keyData.key_rotation_email || "");
const [neverExpire, setNeverExpire] = useState<boolean>(!keyData.expires);
const [isKeySaving, setIsKeySaving] = useState(false);
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
@ -238,6 +239,10 @@ export function KeyEditView({
}
}, [rotationInterval, form]);
useEffect(() => {
form.setFieldValue("key_rotation_email", keyRotationEmail || null);
}, [keyRotationEmail, form]);
// Fetch tags for selector
useEffect(() => {
const fetchTags = async () => {
@ -707,6 +712,8 @@ export function KeyEditView({
onAutoRotationChange={setAutoRotationEnabled}
rotationInterval={rotationInterval}
onRotationIntervalChange={setRotationInterval}
keyRotationEmail={keyRotationEmail}
onKeyRotationEmailChange={setKeyRotationEmail}
neverExpire={neverExpire}
onNeverExpireChange={setNeverExpire}
/>
@ -732,6 +739,9 @@ export function KeyEditView({
<Form.Item name="rotation_interval" hidden>
<Input />
</Form.Item>
<Form.Item name="key_rotation_email" hidden>
<Input />
</Form.Item>
<div className="sticky z-10 bg-white p-4 border-t border-gray-200 bottom-[-1.5rem] inset-x-[-1.5rem]">
<div className="flex justify-end items-center gap-2">