Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5d33cc6
Add unit tests for 5 previously untested UI dashboard files
yuneng-jiang Mar 16, 2026
eba8df5
Merge pull request #23773 from BerriAI/litellm_/reverent-panini
yuneng-jiang Mar 16, 2026
bc810f9
[Fix] Privilege escalation: restrict /key/block, /key/unblock, and ma…
yuneng-jiang Mar 16, 2026
55c7ba9
Update litellm/proxy/management_endpoints/key_management_endpoints.py
yuneng-jiang Mar 16, 2026
2d98b49
Merge pull request #23781 from BerriAI/litellm_key_admin_privilege_es…
yuneng-jiang Mar 16, 2026
c37cf23
Merge remote-tracking branch 'origin' into litellm_ui_logs_filter_2
yuneng-jiang Mar 16, 2026
57bba3b
[Fix] UI - Logs: Fix empty filter results showing stale data
yuneng-jiang Mar 16, 2026
31a677e
Merge pull request #23792 from BerriAI/litellm_ui_logs_filter_2
yuneng-jiang Mar 16, 2026
bc752fb
[Fix] Prevent internal users from creating invalid keys via key/gener…
yuneng-jiang Mar 17, 2026
208740a
[Fix] Remove duplicate get_team_object call in _validate_update_key_data
yuneng-jiang Mar 17, 2026
c8c4774
Merge pull request #23795 from BerriAI/litellm_fix_internal_user_inva…
yuneng-jiang Mar 17, 2026
4a92db8
[Fix] Skip key_alias re-validation on update/regenerate when alias un…
yuneng-jiang Mar 17, 2026
616b311
Merge pull request #23798 from BerriAI/litellm_skip_alias_revalidatio…
yuneng-jiang Mar 17, 2026
a771fe5
[Fix] Update log filter test to match empty-result behavior
yuneng-jiang Mar 17, 2026
53d96c8
[Feature] Disable custom API key values via UI setting
yuneng-jiang Mar 17, 2026
72aa5fc
[Fix] Add disable_custom_api_keys to UISettings Pydantic model
yuneng-jiang Mar 17, 2026
c687e63
[Feature] Add disable_custom_api_keys toggle to UI Settings page
yuneng-jiang Mar 17, 2026
0b0fe7e
[Fix] Rename toggle label to "Disable custom Virtual key values"
yuneng-jiang Mar 17, 2026
471e0f1
[Fix] Remove "API" from custom key description text
yuneng-jiang Mar 17, 2026
a087c44
Merge pull request #23812 from BerriAI/litellm_disable_custom_api_keys
yuneng-jiang Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 180 additions & 8 deletions litellm/proxy/management_endpoints/key_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
_set_object_metadata_field,
)
Expand All @@ -71,6 +72,9 @@
)
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.proxy.spend_tracking.spend_tracking_utils import _is_master_key
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
get_ui_settings_cached,
)
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
Expand All @@ -95,6 +99,24 @@
)


async def _check_custom_key_allowed(custom_key_value: Optional[str]) -> None:
"""Raise 403 if custom API keys are disabled and a custom key was provided."""
if custom_key_value is None:
return

ui_settings = await get_ui_settings_cached()
if ui_settings.get("disable_custom_api_keys", False) is True:
verbose_proxy_logger.warning(
"Custom API key rejected: disable_custom_api_keys is enabled"
)
raise HTTPException(
status_code=403,
detail={
"error": "Custom API key values are disabled by your administrator. Keys must be auto-generated."
},
)


def _is_team_key(data: Union[GenerateKeyRequest, LiteLLM_VerificationToken]):
return data.team_id is not None

Expand Down Expand Up @@ -353,14 +375,24 @@ def key_generation_check(

## check if key is for team or individual
is_team_key = _is_team_key(data=data)
_is_admin = (
user_api_key_dict.user_role is not None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if is_team_key:
if team_table is None and litellm.key_generation_settings is not None:
raise HTTPException(
status_code=400,
detail=f"Unable to find team object in database. Team ID: {data.team_id}",
)
elif team_table is None:
return True # assume user is assigning team_id without using the team table
if _is_admin:
return True # admins can assign team_id without team table
# Non-admin callers must have a valid team (LIT-1884)
raise HTTPException(
status_code=400,
detail=f"Unable to find team object in database. Team ID: {data.team_id}",
)
return _team_key_generation_check(
team_table=team_table,
user_api_key_dict=user_api_key_dict,
Expand Down Expand Up @@ -660,6 +692,9 @@ async def _common_key_generation_helper( # noqa: PLR0915
prisma_client=prisma_client,
)

# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.key)

# Validate user-provided key format
if data.key is not None and not data.key.startswith("sk-"):
_masked = (
Expand Down Expand Up @@ -1213,6 +1248,19 @@ async def generate_key_fn(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=message
)
# For non-admin internal users: auto-assign caller's user_id if not provided
# This prevents creating unbound keys with no user association (LIT-1884)
_is_proxy_admin = (
user_api_key_dict.user_role is not None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if not _is_proxy_admin and data.user_id is None:
data.user_id = user_api_key_dict.user_id
verbose_proxy_logger.warning(
"key/generate: auto-assigning user_id=%s for non-admin caller",
user_api_key_dict.user_id,
)
Comment on lines +1257 to +1262
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Backwards-incompatible auto-assignment of user_id without feature flag

Non-admin internal users who previously called /key/generate without a user_id (e.g. to create shared/service keys) will now have the caller's user_id silently auto-assigned to the key. This is a breaking behavior change — existing automation scripts or integrations relying on creating unbound keys will produce different results without any opt-in.

Per the repo's backwards-compatibility policy, this should be gated behind a feature flag (e.g. litellm.enforce_key_user_id_binding = True) so existing deployments are not broken.

# Safer approach — opt-in flag
if not _is_proxy_admin and data.user_id is None and litellm.enforce_key_user_id_binding:
    data.user_id = user_api_key_dict.user_id
    verbose_proxy_logger.warning(...)

Rule Used: What: avoid backwards-incompatible changes without... (source)


team_table: Optional[LiteLLM_TeamTableCachedObj] = None
if data.team_id is not None:
try:
Expand All @@ -1227,6 +1275,12 @@ async def generate_key_fn(
verbose_proxy_logger.debug(
f"Error getting team object in `/key/generate`: {e}"
)
# For non-admin callers, team must exist (LIT-1884)
if not _is_proxy_admin:
raise HTTPException(
status_code=400,
detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot create keys for non-existent teams.",
)

key_generation_check(
team_table=team_table,
Expand Down Expand Up @@ -1809,11 +1863,26 @@ async def _validate_update_key_data(
user_api_key_cache: Any,
) -> None:
"""Validate permissions and constraints for key update."""
_is_proxy_admin = (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)

# Prevent non-admin from removing user_id (setting to empty string) (LIT-1884)
if (
data.user_id is not None
and data.user_id == ""
and not _is_proxy_admin
):
raise HTTPException(
status_code=403,
detail="Non-admin users cannot remove the user_id from a key.",
)

# sanity check - prevent non-proxy admin user from updating key to belong to a different user
if (
data.user_id is not None
and data.user_id != existing_key_row.user_id
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _is_proxy_admin
):
raise HTTPException(
status_code=403,
Expand All @@ -1836,6 +1905,18 @@ async def _validate_update_key_data(
user_api_key_cache=user_api_key_cache,
)

# Admin-only: only proxy admins, team admins, or org admins can modify max_budget
if data.max_budget is not None and data.max_budget != existing_key_row.max_budget:
if prisma_client is not None:
hashed_key = existing_key_row.token
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_key,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/update (max_budget)",
)

# Check team limits if key has a team_id (from request or existing key)
team_obj: Optional[LiteLLM_TeamTableCachedObj] = None
_team_id_to_check = data.team_id or getattr(existing_key_row, "team_id", None)
Expand All @@ -1847,6 +1928,13 @@ async def _validate_update_key_data(
check_db_only=True,
)

# Validate team exists when non-admin sets a new team_id (LIT-1884)
if team_obj is None and data.team_id is not None and not _is_proxy_admin:
raise HTTPException(
status_code=400,
detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.",
)

if team_obj is not None:
await _check_team_key_limits(
team_table=team_obj,
Expand Down Expand Up @@ -2056,7 +2144,10 @@ async def update_key_fn(
data=data, existing_key_row=existing_key_row
)

_validate_key_alias_format(key_alias=non_default_values.get("key_alias", None))
# Only validate key_alias format if it's actually being changed
new_key_alias = non_default_values.get("key_alias", None)
if new_key_alias != existing_key_row.key_alias:
_validate_key_alias_format(key_alias=new_key_alias)

await _enforce_unique_key_alias(
key_alias=non_default_values.get("key_alias", None),
Expand Down Expand Up @@ -3412,8 +3503,10 @@ async def _rotate_master_key( # noqa: PLR0915
)


def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
if data and data.new_key is not None:
# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.new_key)
new_token = data.new_key
if not data.new_key.startswith("sk-"):
raise HTTPException(
Expand Down Expand Up @@ -3505,7 +3598,7 @@ async def _execute_virtual_key_regeneration(
"""Generate new token, update DB, invalidate cache, and return response."""
from litellm.proxy.proxy_server import hash_token

new_token = get_new_token(data=data)
new_token = await get_new_token(data=data)
new_token_hash = hash_token(new_token)
new_token_key_name = f"sk-...{new_token[-4:]}"
update_data = {"token": new_token_hash, "key_name": new_token_key_name}
Expand All @@ -3515,7 +3608,10 @@ async def _execute_virtual_key_regeneration(
non_default_values = await prepare_key_update_data(
data=data, existing_key_row=key_in_db
)
_validate_key_alias_format(key_alias=non_default_values.get("key_alias"))
# Only validate key_alias format if it's actually being changed
new_key_alias = non_default_values.get("key_alias")
if new_key_alias != key_in_db.key_alias:
_validate_key_alias_format(key_alias=new_key_alias)
verbose_proxy_logger.debug("non_default_values: %s", non_default_values)
update_data.update(non_default_values)
update_data = prisma_client.jsonify_object(data=update_data)
Expand Down Expand Up @@ -4733,6 +4829,64 @@ def _get_condition_to_filter_out_ui_session_tokens() -> Dict[str, Any]:
}


async def _check_key_admin_access(
user_api_key_dict: UserAPIKeyAuth,
hashed_token: str,
prisma_client: Any,
user_api_key_cache: DualCache,
route: str,
) -> None:
"""
Check that the caller has admin privileges for the target key.

Allowed callers:
- Proxy admin
- Team admin for the key's team
- Org admin for the key's team's organization

Raises HTTPException(403) if the caller is not authorized.
"""

if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return

# Look up the target key to find its team
target_key_row = await prisma_client.db.litellm_verificationtoken.find_unique(
where={"token": hashed_token}
)
Comment on lines +4854 to +4856
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Direct DB query bypasses established helper-function pattern

_check_key_admin_access issues a raw prisma_client.db.litellm_verificationtoken.find_unique call instead of going through the existing get_key_object helper. This bypasses DualCache (so the key record is fetched from DB on every block/unblock/update call even if it was recently read) and is inconsistent with the pattern used elsewhere in this file, where all key look-ups go through get_key_object / get_team_object. Any caching logic, telemetry spans, or future changes to key lookup behaviour won't automatically apply here.

Consider using the get_key_object helper instead, which handles cache read/write and is consistent with the rest of the codebase.

Rule Used: What: In critical path of request, there should be... (source)

if target_key_row is None:
raise HTTPException(
status_code=404,
detail={"error": f"Key not found: {hashed_token}"},
)

# If the key belongs to a team, check team admin / org admin
if target_key_row.team_id:
team_obj = await get_team_object(
team_id=target_key_row.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
if team_obj is not None:
if _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return
if await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return

raise HTTPException(
status_code=403,
detail={
"error": f"Only proxy admins, team admins, or org admins can call {route}. "
f"user_role={user_api_key_dict.user_role}, user_id={user_api_key_dict.user_id}"
},
)


@router.post(
"/key/block", tags=["key management"], dependencies=[Depends(user_api_key_auth)]
)
Expand Down Expand Up @@ -4762,7 +4916,7 @@ async def block_key(
}'
```

Note: This is an admin-only endpoint. Only proxy admins can block keys.
Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can block keys.
"""
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
Expand All @@ -4788,6 +4942,15 @@ async def block_key(
else:
hashed_token = data.key

# Admin-only: only proxy admins, team admins, or org admins can block keys
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/block",
)

if litellm.store_audit_logs is True:
# make an audit log for key update
record = await prisma_client.db.litellm_verificationtoken.find_unique(
Expand Down Expand Up @@ -4876,7 +5039,7 @@ async def unblock_key(
}'
```

Note: This is an admin-only endpoint. Only proxy admins can unblock keys.
Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can unblock keys.
"""
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
Expand All @@ -4902,6 +5065,15 @@ async def unblock_key(
else:
hashed_token = data.key

# Admin-only: only proxy admins, team admins, or org admins can unblock keys
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/unblock",
)

if litellm.store_audit_logs is True:
# make an audit log for key update
record = await prisma_client.db.litellm_verificationtoken.find_unique(
Expand Down
6 changes: 6 additions & 0 deletions litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ class UISettings(BaseModel):
description="If enabled, the user search endpoint (/user/filter/ui) restricts results by organization. When off, any authenticated user can search all users.",
)

disable_custom_api_keys: bool = Field(
default=False,
description="If true, users cannot specify custom key values. All keys must be auto-generated.",
)


class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
Expand All @@ -149,6 +154,7 @@ class UISettingsResponse(SettingsResponse):
"disable_vector_stores_for_internal_users",
"allow_vector_stores_for_team_admins",
"scope_user_search_to_org",
"disable_custom_api_keys",
}

# Flags that must be synced from the persisted UISettings into
Expand Down
Loading
Loading