Proxy - specify key_type - allows specifying if key can call LLM API routes vs. Management routes only (#12909)

* feat(key_management_endpoints.py): Support new 'key_type' field

allow user to specify if key should be 'management' or 'llm api' key

Security fix

* test(test_route_checks.py): add unit tests

* fix(create_key_button.tsx): add ui component to select key type

allows specifying if key can call llm api vs. management routes

* feat(create_key_button.tsx): add specifying key type to ui

* fix(route_checks.py): add sensitive data masker for user id on not allowed error message

prevent leaking sensitive information
This commit is contained in:
Krish Dholakia
2025-07-24 16:40:40 -07:00
committed by GitHub
parent 550211ba47
commit 1a57875d24
5 changed files with 289 additions and 34 deletions
+17 -2
View File
@@ -696,9 +696,24 @@ class KeyRequestBase(GenerateRequestBase):
allowed_routes: Optional[list] = []
class LiteLLMKeyType(str, enum.Enum):
"""
Enum for key types that determine what routes a key can access
"""
LLM_API = "llm_api" # Can call LLM API routes (chat/completions, embeddings, etc.)
MANAGEMENT = "management" # Can call management routes (user/team/key management)
READ_ONLY = "read_only" # Can only call info/read routes
DEFAULT = "default" # Uses default allowed routes
class GenerateKeyRequest(KeyRequestBase):
soft_budget: Optional[float] = None
send_invite_email: Optional[bool] = None
key_type: Optional[LiteLLMKeyType] = Field(
default=LiteLLMKeyType.DEFAULT,
description="Type of key that determines default allowed routes.",
)
class GenerateKeyResponse(KeyRequestBase):
@@ -1766,16 +1781,16 @@ class UserAPIKeyAuth(
if JWTHandler.is_jwt(token=api_key):
return f"hashed-jwt-{hash_token(token=api_key)}"
return api_key
@classmethod
def get_litellm_internal_health_check_user_api_key_auth(cls) -> "UserAPIKeyAuth":
"""
Returns a `UserAPIKeyAuth` object for the litellm internal health check service account.
This is used to track number of requests/spend for health check calls.
"""
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
return cls(
api_key=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
team_id=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
+37 -1
View File
@@ -54,6 +54,19 @@ class RouteChecks:
if route in valid_token.allowed_routes:
return True
## check if 'allowed_route' is a field name in LiteLLMRoutes
if any(
allowed_route in LiteLLMRoutes._member_names_
for allowed_route in valid_token.allowed_routes
):
for allowed_route in valid_token.allowed_routes:
if allowed_route in LiteLLMRoutes._member_names_:
if RouteChecks.check_route_access(
route=route,
allowed_routes=LiteLLMRoutes._member_map_[allowed_route].value,
):
return True
# check if wildcard pattern is allowed
for allowed_route in valid_token.allowed_routes:
if RouteChecks._route_matches_wildcard_pattern(
@@ -65,6 +78,27 @@ class RouteChecks:
f"Virtual key is not allowed to call this route. Only allowed to call routes: {valid_token.allowed_routes}. Tried to call route: {route}"
)
@staticmethod
def _mask_user_id(user_id: str) -> str:
"""
Mask user_id to prevent leaking sensitive information in error messages
Args:
user_id (str): The user_id to mask
Returns:
str: Masked user_id showing only first 2 and last 2 characters
"""
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
if not user_id or len(user_id) <= 4:
return "***"
# Use SensitiveDataMasker with custom configuration for user_id
masker = SensitiveDataMasker(visible_prefix=6, visible_suffix=2, mask_char="*")
return masker._mask_value(user_id)
@staticmethod
def non_proxy_admin_allowed_routes_check(
user_obj: Optional[LiteLLM_UserTable],
@@ -182,8 +216,10 @@ class RouteChecks:
if user_obj is not None:
user_role = user_obj.user_role or "unknown"
user_id = user_obj.user_id or "unknown"
masked_user_id = RouteChecks._mask_user_id(user_id)
raise Exception(
f"Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route={route}. Your role={user_role}. Your user_id={user_id}"
f"Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}"
)
@staticmethod
@@ -46,8 +46,8 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
_add_model_to_db,
)
from litellm.proxy.management_helpers.object_permission_utils import (
handle_update_object_permission_common,
attach_object_permission_to_dict,
handle_update_object_permission_common,
)
from litellm.proxy.management_helpers.team_member_permission_checks import (
TeamMemberPermissionChecks,
@@ -331,6 +331,21 @@ def common_key_access_checks(
router = APIRouter()
def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict:
"""
Handle the key type.
"""
key_type = data.key_type
data_json.pop("key_type", None)
if key_type == LiteLLMKeyType.LLM_API:
data_json["allowed_routes"] = ["llm_api_routes"]
elif key_type == LiteLLMKeyType.MANAGEMENT:
data_json["allowed_routes"] = ["management_routes"]
elif key_type == LiteLLMKeyType.READ_ONLY:
data_json["allowed_routes"] = ["info_routes"]
return data_json
async def _common_key_generation_helper( # noqa: PLR0915
data: GenerateKeyRequest,
user_api_key_dict: UserAPIKeyAuth,
@@ -455,6 +470,7 @@ async def _common_key_generation_helper( # noqa: PLR0915
data_json = data.model_dump(exclude_unset=True, exclude_none=True) # type: ignore
data_json = handle_key_type(data, data_json)
# if we get max_budget passed to /key/generate, then use it as key_max_budget. Since generate_key_helper_fn is used to make new users
if "max_budget" in data_json:
data_json["key_max_budget"] = data_json.pop("max_budget", None)
@@ -2600,7 +2616,7 @@ async def _list_key_helper(
key_list.append(UserAPIKeyAuth(**key_dict)) # Return full key object
else:
_token = key_dict.get("token")
key_list.append(_token) # Return only the token
key_list.append(cast(str, _token)) # Return only the token
return KeyListResponseObject(
keys=key_list,
@@ -3099,6 +3115,3 @@ def validate_model_max_budget(model_max_budget: Optional[Dict]) -> None:
raise ValueError(
f"Invalid model_max_budget: {str(e)}. Example of valid model_max_budget: https://docs.litellm.ai/docs/proxy/users"
)
@@ -89,3 +89,119 @@ def test_proxy_admin_viewer_config_update_route_rejected():
assert exc_info.value.status_code == 403
assert "user not allowed to access this route" in str(exc_info.value.detail)
assert "role= proxy_admin_viewer" in str(exc_info.value.detail)
def test_virtual_key_allowed_routes_with_litellm_routes_member_name_allowed():
"""Test that virtual key is allowed to call routes when allowed_routes contains LiteLLMRoutes member name"""
# Create a UserAPIKeyAuth with allowed_routes containing a LiteLLMRoutes member name
valid_token = UserAPIKeyAuth(
user_id="test_user",
allowed_routes=["openai_routes"], # This is a member name in LiteLLMRoutes enum
)
# Test that a route from the openai_routes group is allowed
result = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/chat/completions", # This is in LiteLLMRoutes.openai_routes.value
valid_token=valid_token,
)
assert result is True
def test_virtual_key_allowed_routes_with_litellm_routes_member_name_denied():
"""Test that virtual key is denied when route is not in the allowed LiteLLMRoutes group"""
# Create a UserAPIKeyAuth with allowed_routes containing a LiteLLMRoutes member name
valid_token = UserAPIKeyAuth(
user_id="test_user",
allowed_routes=["info_routes"], # This is a member name in LiteLLMRoutes enum
)
# Test that a route NOT in the info_routes group raises an exception
with pytest.raises(Exception) as exc_info:
RouteChecks.is_virtual_key_allowed_to_call_route(
route="/chat/completions", # This is NOT in LiteLLMRoutes.info_routes.value
valid_token=valid_token,
)
# Verify the exception message
assert "Virtual key is not allowed to call this route" in str(exc_info.value)
assert "Only allowed to call routes: ['info_routes']" in str(exc_info.value)
assert "Tried to call route: /chat/completions" in str(exc_info.value)
def test_virtual_key_allowed_routes_with_multiple_litellm_routes_member_names():
"""Test that virtual key works with multiple LiteLLMRoutes member names in allowed_routes"""
# Create a UserAPIKeyAuth with multiple LiteLLMRoutes member names
valid_token = UserAPIKeyAuth(
user_id="test_user", allowed_routes=["openai_routes", "info_routes"]
)
# Test that routes from both groups are allowed
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/chat/completions", valid_token=valid_token # This is in openai_routes
)
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/user/info", valid_token=valid_token # This is in info_routes
)
assert result1 is True
assert result2 is True
def test_virtual_key_allowed_routes_with_mixed_member_names_and_explicit_routes():
"""Test that virtual key works with both LiteLLMRoutes member names and explicit routes"""
# Create a UserAPIKeyAuth with both member names and explicit routes
valid_token = UserAPIKeyAuth(
user_id="test_user",
allowed_routes=[
"info_routes",
"/custom/route",
], # Mix of member name and explicit route
)
# Test that both info routes and explicit custom route are allowed
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/user/info", valid_token=valid_token # This is in info_routes
)
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/custom/route", valid_token=valid_token # This is explicitly listed
)
assert result1 is True
assert result2 is True
def test_virtual_key_allowed_routes_with_no_member_names_only_explicit():
"""Test that virtual key works when allowed_routes contains only explicit routes (no member names)"""
# Create a UserAPIKeyAuth with only explicit routes (no LiteLLMRoutes member names)
valid_token = UserAPIKeyAuth(
user_id="test_user",
allowed_routes=["/chat/completions", "/custom/route"], # Only explicit routes
)
# Test that explicit routes are allowed
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/chat/completions", valid_token=valid_token
)
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
route="/custom/route", valid_token=valid_token
)
assert result1 is True
assert result2 is True
# Test that non-allowed route raises exception
with pytest.raises(Exception) as exc_info:
RouteChecks.is_virtual_key_allowed_to_call_route(
route="/user/info", valid_token=valid_token # Not in allowed routes
)
assert "Virtual key is not allowed to call this route" in str(exc_info.value)
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Button, TextInput, Grid, Col } from "@tremor/react";
import { Button, TextInput, Grid, Col, Select as TremorSelect, SelectItem } from "@tremor/react";
import {
Card,
Metric,
@@ -182,12 +182,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false);
const [disabledCallbacks, setDisabledCallbacks] = useState<string[]>([]);
const [keyType, setKeyType] = useState<string>("default");
const handleOk = () => {
setIsModalVisible(false);
form.resetFields();
setLoggingSettings([]);
setDisabledCallbacks([]);
setKeyType("default");
};
const handleCancel = () => {
@@ -197,6 +199,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
form.resetFields();
setLoggingSettings([]);
setDisabledCallbacks([]);
setKeyType("default");
};
useEffect(() => {
@@ -591,31 +594,40 @@ const CreateKey: React.FC<CreateKeyProps> = ({
>
<TextInput placeholder="" />
</Form.Item>
<Form.Item
label={
<span>
Models{' '}
<Tooltip title="Select which models this key can access. Choose 'All Team Models' to grant access to all models available to the team">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="models"
rules={[{ required: true, message: "Please select a model" }]}
help="required"
className="mt-4"
>
<Select
mode="multiple"
placeholder="Select models"
style={{ width: "100%" }}
onChange={(values) => {
if (values.includes("all-team-models")) {
form.setFieldsValue({ models: ["all-team-models"] });
}
}}
>
<Form.Item
label={
<span>
Models{' '}
<Tooltip title="Select which models this key can access. Choose 'All Team Models' to grant access to all models available to the team">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="models"
rules={
keyType === "management" || keyType === "read_only"
? []
: [{ required: true, message: "Please select a model" }]
}
help={
keyType === "management" || keyType === "read_only"
? "Models field is disabled for this key type"
: "required"
}
className="mt-4"
>
<Select
mode="multiple"
placeholder="Select models"
style={{ width: "100%" }}
disabled={keyType === "management" || keyType === "read_only"}
onChange={(values) => {
if (values.includes("all-team-models")) {
form.setFieldsValue({ models: ["all-team-models"] });
}
}}
>
<Option key="all-team-models" value="all-team-models">
All Team Models
</Option>
@@ -626,6 +638,69 @@ const CreateKey: React.FC<CreateKeyProps> = ({
))}
</Select>
</Form.Item>
<Form.Item
label={
<span>
Key Type{' '}
<Tooltip title="Select the type of key to determine what routes and operations this key can access">
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
}
name="key_type"
initialValue="default"
className="mt-4"
>
<Select
defaultValue="default"
placeholder="Select key type"
style={{ width: "100%" }}
optionLabelProp="label"
onChange={(value) => {
setKeyType(value);
// Clear models field and disable if management or read_only
if (value === "management" || value === "read_only") {
form.setFieldsValue({ models: [] });
}
}}
>
<Option value="default" label="Default">
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>Default</div>
<div style={{ fontSize: '11px', color: '#6b7280', marginTop: '2px' }}>
Can call LLM API + Management routes
</div>
</div>
</Option>
<Option value="llm_api" label="LLM API">
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>LLM API</div>
<div style={{ fontSize: '11px', color: '#6b7280', marginTop: '2px' }}>
Can call only LLM API routes (chat/completions, embeddings, etc.)
</div>
</div>
</Option>
<Option value="management" label="Management">
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>Management</div>
<div style={{ fontSize: '11px', color: '#6b7280', marginTop: '2px' }}>
Can call only management routes (user/team/key management)
</div>
</div>
</Option>
<Option value="read_only" label="Read Only">
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>Read Only</div>
<div style={{ fontSize: '11px', color: '#6b7280', marginTop: '2px' }}>
Can only call only info routes (e.g. /key/info, /user/info, /team/info)
</div>
</div>
</Option>
</Select>
</Form.Item>
</div>
)}