Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions litellm/proxy/management_endpoints/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,34 @@ def _check_passthrough_routes_caller_permission(
)


def _check_disable_global_guardrails_caller_permission(
disable_global_guardrails: bool | None,
metadata: Mapping[str, object] | None,
user_api_key_dict: UserAPIKeyAuth,
*,
entity: str = "key",
existing_metadata: Mapping[str, object] | None = None,
) -> None:
"""
Only proxy admins may opt a key or team out of default-on guardrails, whether the
flag is top-level or under `metadata`. Re-sending a flag that is already stored is
not an opt-out, so non-admin edits of an already exempted object still go through.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return
requested: Final = bool(disable_global_guardrails) or (
metadata is not None and bool(metadata.get("disable_global_guardrails"))
)
if not requested:
return
if existing_metadata is not None and existing_metadata.get("disable_global_guardrails") is True:
return
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
raise HTTPException(
status_code=403,
detail={"error": f"Only proxy admins can set `disable_global_guardrails` on a {entity}."},
)


def _is_user_team_admin(user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable) -> bool:
for member in team_obj.members_with_roles:
if (member.user_id is not None and member.user_id == user_api_key_dict.user_id) and member.role == "admin":
Expand Down
45 changes: 43 additions & 2 deletions litellm/proxy/management_endpoints/key_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.hooks.model_max_budget_limiter import build_model_max_budget_usage
from litellm.proxy.management_endpoints.common_utils import (
_check_disable_global_guardrails_caller_permission,
_check_passthrough_routes_caller_permission,
_is_user_org_admin_for_team,
_is_user_team_admin,
Expand Down Expand Up @@ -1223,6 +1224,7 @@ async def _common_key_generation_helper(
# default_key_generate_params injected.
_requested_max_budget: Final = data.max_budget
_requested_team_id: Final = data.team_id
_requested_metadata: Final = data.metadata # pyright: ignore[reportUnknownMemberType] # request models declare `metadata` as bare dict

# check if user set default key/generate params on config.yaml
if litellm.default_key_generate_params is not None:
Expand Down Expand Up @@ -1310,6 +1312,11 @@ async def _common_key_generation_helper(
data=data,
user_api_key_dict=user_api_key_dict,
)
_check_disable_global_guardrails_caller_permission(
data.disable_global_guardrails,
_requested_metadata,
user_api_key_dict,
)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

# APPLY ENTERPRISE KEY MANAGEMENT PARAMS
try:
Expand Down Expand Up @@ -1965,7 +1972,7 @@ async def generate_key_fn(
- metadata: Optional[dict] - Metadata for key, store information for key. Example metadata = {"team": "core-infra", "app": "app2", "email": "ishaan@berri.ai" }
- guardrails: Optional[List[str]] - List of active guardrails for the key
- policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key. Proxy admin only.
- throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
- enable_prompt_caching: Optional[bool] - Auto-inject prompt caching breakpoints (Anthropic cache_control markers) on requests made with this key. Supported Claude models on Anthropic, Bedrock, Vertex AI, and Azure AI only.
- permissions: Optional[dict] - key-specific permissions. Currently just used for turning off pii masking (if connected). Example - {"pii": false}
Expand Down Expand Up @@ -2728,6 +2735,13 @@ async def _process_single_key_update(
prisma_client=prisma_client,
)

_check_disable_global_guardrails_caller_permission(
update_key_request.disable_global_guardrails,
update_key_request.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
existing_metadata=existing_key_row.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
)

enforce_batch_enqueued_token_limit_is_admin_only(
data=update_key_request,
existing_metadata=existing_key_row.metadata,
Expand Down Expand Up @@ -3017,6 +3031,12 @@ async def _validate_update_key_data(
data=data,
user_api_key_dict=user_api_key_dict,
)
_check_disable_global_guardrails_caller_permission(
data.disable_global_guardrails,
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
existing_metadata=existing_key_row.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
)

_validate_caller_can_change_key_ownership(
data=data,
Expand Down Expand Up @@ -3320,7 +3340,7 @@ async def update_key_fn(
- send_invite_email: Optional[bool] - Send invite email to user_id
- guardrails: Optional[List[str]] - List of active guardrails for the key
- policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key. Proxy admin only.
- throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
- enable_prompt_caching: Optional[bool] - Auto-inject prompt caching breakpoints (Anthropic cache_control markers) on requests made with this key. Supported Claude models on Anthropic, Bedrock, Vertex AI, and Azure AI only.
- prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
Expand Down Expand Up @@ -5600,6 +5620,21 @@ async def _execute_virtual_key_regeneration(
return response


def _check_regenerate_guardrail_opt_out(
data: RegenerateKeyRequest | None,
existing_metadata: Mapping[str, object] | None,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
if data is None:
return
_check_disable_global_guardrails_caller_permission(
data.disable_global_guardrails,
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
existing_metadata=existing_metadata,
)


@router.post(
"/key/{key:path}/regenerate",
tags=["key management"],
Expand Down Expand Up @@ -5783,6 +5818,12 @@ async def regenerate_key_fn(
detail={"error": f"Key {key} not found."},
)

_check_regenerate_guardrail_opt_out(
data,
_key_in_db.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
user_api_key_dict,
)

# check if user has permission to regenerate key
await TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint(
user_api_key_dict=user_api_key_dict,
Expand Down
18 changes: 16 additions & 2 deletions litellm/proxy/management_endpoints/team_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
get_daily_activity_aggregated,
)
from litellm.proxy.management_endpoints.common_utils import (
_check_disable_global_guardrails_caller_permission,
_check_passthrough_routes_caller_permission,
_is_user_org_admin_for_team,
_is_user_team_admin,
Expand Down Expand Up @@ -1416,7 +1417,7 @@ async def new_team(
- model_max_budget: Optional[dict] - Per-model max budget every key on the team inherits unless the key sets its own for that model. Example: {"gpt-4o": {"max_budget": 10, "budget_duration": "1d"}}
- guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
- policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies)
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the team. Proxy admin only.
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
- team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
- team_member_budget_duration: Optional[str] - The duration of the budget for the team member. Doc [here](https://docs.litellm.ai/docs/proxy/team_budgets)
Expand Down Expand Up @@ -1638,6 +1639,12 @@ async def new_team(
data.members_with_roles.append(Member(role="admin", user_id=user_api_key_dict.user_id))

_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
_check_disable_global_guardrails_caller_permission(
data.disable_global_guardrails,
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
entity="team",
)

if isinstance(data.metadata, dict):
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
Expand Down Expand Up @@ -2172,7 +2179,7 @@ async def update_team(
- model_max_budget: Optional[dict] - Per-model max budget every key on the team inherits unless the key sets its own for that model. Example: {"gpt-4o": {"max_budget": 10, "budget_duration": "1d"}}
- guardrails: Optional[List[str]] - Guardrails for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails)
- policies: Optional[List[str]] - Policies for the team. [Docs](https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies)
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the team. Proxy admin only.
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - team-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
- team_member_budget: Optional[float] - The maximum budget allocated to an individual team member.
- team_member_budget_duration: Optional[str] - The duration of the budget for the team member. Doc [here](https://docs.litellm.ai/docs/proxy/team_budgets)
Expand Down Expand Up @@ -2313,6 +2320,13 @@ async def update_team(
)

_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
_check_disable_global_guardrails_caller_permission(
data.disable_global_guardrails,
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
user_api_key_dict,
entity="team",
existing_metadata=_existing_team_metadata if isinstance(_existing_team_metadata, dict) else None, # pyright: ignore[reportUnknownArgumentType] # existing_team_row.metadata is a bare dict
)

if data.soft_budget is not None:
max_budget_to_check = data.max_budget if data.max_budget is not None else existing_team_row.max_budget
Expand Down
71 changes: 71 additions & 0 deletions tests/integration/authorization/_guardrail_opt_out.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import json
import uuid
from hashlib import sha256
from pathlib import Path
from typing import Final

import httpx
import yaml
from pydantic import JsonValue

from integration._support.client import Gateway, Scenario, object_value
from integration._support.database import read_rows
from integration._support.wire import Reply, Request

MANAGEMENT_ROUTES: Final = ["/key/*", "/team/new", "/team/update", "/v1/chat/completions"]


def denying_guardrail(request: Request) -> Reply:
assert request.target == "/beta/litellm_basic_guardrail_api"
return Reply(body=json.dumps({"action": "BLOCKED", "blocked_reason": "synthetic policy denial"}).encode())


def guardrail_config(policy_url: str, path: Path) -> Path:
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config["guardrails"] = [
{
"guardrail_name": "guardrail" + uuid.uuid4().hex,
"litellm_params": {
"guardrail": "generic_guardrail_api",
"mode": "pre_call",
"default_on": True,
"api_base": policy_url,
"api_key": "synthetic-guardrail-key",
},
}
]
path.write_text(yaml.safe_dump(config))
return path


def stored_metadata(token: str) -> dict[str, object]:
rows: Final = read_rows(
'SELECT metadata FROM "LiteLLM_VerificationToken" WHERE token = %s', (sha256(token.encode()).hexdigest(),)
)
assert len(rows) == 1, rows
return rows[0]["metadata"]


def non_admin_caller(scenario: Scenario, member: str, team: str, model: str) -> str:
return scenario.key(user_id=member, team_id=team, models=[model], allowed_routes=MANAGEMENT_ROUTES)


def chat(candidate: Gateway, model: str, key: str, marker: str, *, stream: bool = False) -> httpx.Response:
return candidate.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": marker}], "stream": stream},
key=key,
)


def upstream_observations(gateway: Gateway) -> tuple[dict[str, JsonValue], ...]:
with httpx.Client(timeout=5, trust_env=False) as client:
drained: Final = object_value(client.get(f"{gateway.upstream_url}/__observations").json())
requests: Final = drained["requests"]
assert isinstance(requests, list)
return tuple(object_value(entry) for entry in requests)


def upstream_hits(gateway: Gateway, marker: str) -> int:
return sum(1 for entry in upstream_observations(gateway) if marker in json.dumps(entry.get("body")))
Loading
Loading