From e777ebeefe1937b8eb25814cc53bb9509451b098 Mon Sep 17 00:00:00 2001 From: Ganni Galea Curmi Date: Tue, 26 May 2026 21:08:29 -0400 Subject: [PATCH] fix(policy): reject empty specific attachments --- .../proxy/policy_engine/resolver_types.py | 15 ++++++++- .../policy_engine/test_resolver_types.py | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/litellm/types/proxy/policy_engine/resolver_types.py b/litellm/types/proxy/policy_engine/resolver_types.py index cb6590688dc..2c49c6c5248 100644 --- a/litellm/types/proxy/policy_engine/resolver_types.py +++ b/litellm/types/proxy/policy_engine/resolver_types.py @@ -8,7 +8,7 @@ the final guardrails list. from datetime import datetime from typing import Any, Dict, List, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class PolicyMatchContext(BaseModel): @@ -326,6 +326,19 @@ class PolicyAttachmentCreateRequest(BaseModel): description="Tag patterns this attachment applies to. Supports wildcards (e.g., health-*).", ) + @model_validator(mode="after") + def validate_scope_has_selector(self) -> "PolicyAttachmentCreateRequest": + if self.scope == "*": + return self + + if any((self.teams, self.keys, self.models, self.tags)): + return self + + raise ValueError( + "Specific policy attachments must include at least one non-empty selector " + "(teams, keys, models, or tags). Use scope='*' for global attachments." + ) + class PolicyAttachmentDBResponse(BaseModel): """Response for a policy attachment from the database.""" diff --git a/tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py b/tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py index c23ed5d4319..51911e59cab 100644 --- a/tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py +++ b/tests/test_litellm/types/proxy/policy_engine/test_resolver_types.py @@ -3,8 +3,10 @@ Tests for pipeline field on policy CRUD types (resolver_types.py). """ import pytest +from pydantic import ValidationError from litellm.types.proxy.policy_engine.resolver_types import ( + PolicyAttachmentCreateRequest, PolicyCreateRequest, PolicyDBResponse, PolicyUpdateRequest, @@ -100,3 +102,33 @@ def test_policy_create_request_roundtrip(): dumped = req.model_dump() restored = PolicyCreateRequest(**dumped) assert restored.pipeline == pipeline_data + + +def test_policy_attachment_create_request_rejects_empty_specific_scope(): + with pytest.raises(ValidationError, match="at least one non-empty selector"): + PolicyAttachmentCreateRequest(policy_name="pii-policy") + + +def test_policy_attachment_create_request_rejects_empty_selector_list(): + with pytest.raises(ValidationError, match="at least one non-empty selector"): + PolicyAttachmentCreateRequest(policy_name="pii-policy", teams=[]) + + +def test_policy_attachment_create_request_allows_explicit_global_scope(): + request = PolicyAttachmentCreateRequest( + policy_name="pii-policy", + scope="*", + ) + + assert request.scope == "*" + + +@pytest.mark.parametrize("selector_field", ["teams", "keys", "models", "tags"]) +def test_policy_attachment_create_request_allows_selector_scope(selector_field): + selector_value = f"{selector_field}-a" + request = PolicyAttachmentCreateRequest( + policy_name="pii-policy", + **{selector_field: [selector_value]}, + ) + + assert getattr(request, selector_field) == [selector_value]