diff --git a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py index b9dd11bb709..24e5ee3af17 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py +++ b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py @@ -280,18 +280,25 @@ class PurviewGuardrailBase: ) -> Optional[str]: """Resolve the Entra user object ID from request data or auth context. - Trust order (strongest first) so client ``metadata[user_id_field]`` cannot - impersonate another Entra user for Purview ``protectionScopes`` / ``processContent``: + Trust order (strongest first). Blocking DLP uses only + ``_resolve_trusted_user_id`` (API-key-bound ``user_id``). This resolver + also supports audit/logging fallbacks: - 1. ``user_api_key_dict.user_id`` — LiteLLM key / internal user - 2. ``user_api_key_dict.end_user_id`` — end-user on the API key - 3. ``metadata["user_api_key_user_id"]`` — proxy-injected from the key (when present) - 4. ``metadata[user_id_field]`` — caller-supplied; used only when none of the above apply + 1. ``user_api_key_dict.user_id`` — LiteLLM key / JWT-bound user + 2. ``user_api_key_dict.end_user_id`` — request-derived; audit only + 3. ``metadata["user_api_key_user_id"]`` — proxy-injected from the key + 4. ``metadata[user_id_field]`` — caller-supplied; audit only """ trusted = self._resolve_trusted_user_id(data, user_api_key_dict) if trusted: return trusted + if ( + hasattr(user_api_key_dict, "end_user_id") + and user_api_key_dict.end_user_id + ): + return str(user_api_key_dict.end_user_id) + metadata = data.get("metadata") or data.get("litellm_metadata") or {} uid = metadata.get("user_api_key_user_id") if uid: @@ -315,20 +322,23 @@ class PurviewGuardrailBase: def _resolve_trusted_user_id( self, data: Dict[str, Any], user_api_key_dict: Any ) -> Optional[str]: - """Resolve user ID from trusted (proxy-authenticated) sources only. + """Resolve user ID from API-key/JWT-bound identity for blocking DLP. - Uses only ``UserAPIKeyAuth.user_id`` and ``UserAPIKeyAuth.end_user_id``. - Intentionally omits ``metadata[user_id_field]`` and - ``metadata["user_api_key_user_id"]`` because those can be supplied or - spoofed by the caller when the API key has no bound user. + Uses only ``UserAPIKeyAuth.user_id`` (bound on the LiteLLM key or JWT). + Intentionally omits ``UserAPIKeyAuth.end_user_id`` because the proxy sets + it from caller-controlled request fields (``user``, ``metadata.user_id``, + ``safety_identifier``, custom headers, etc.) via + ``get_end_user_id_from_request_body``. + + Also omits ``metadata[user_id_field]`` and + ``metadata["user_api_key_user_id"]`` for the same impersonation risk when + the key has no bound user. Returns ``None`` when no authenticated identity is available. Blocking hooks must fail closed rather than skip the DLP check. """ if hasattr(user_api_key_dict, "user_id") and user_api_key_dict.user_id: return str(user_api_key_dict.user_id) - if hasattr(user_api_key_dict, "end_user_id") and user_api_key_dict.end_user_id: - return str(user_api_key_dict.end_user_id) return None diff --git a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py index b8e28f9a861..09a572c0a18 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py +++ b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py @@ -261,11 +261,13 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail): """Resolve user ID for blocking (pre_call / post_call) DLP hooks. Uses only trusted proxy-authenticated sources (``_resolve_trusted_user_id``). - Caller-supplied ``metadata[user_id_field]`` is rejected (fail closed) because - it can impersonate another Entra user's Purview policy. + Caller-supplied ``UserAPIKeyAuth.end_user_id`` (from request ``user``, + ``metadata.user_id``, ``safety_identifier``, etc.) and + ``metadata[user_id_field]`` are rejected (fail closed) because they can + impersonate another Entra user's Purview policy. - Raises ``HTTPException`` when no trusted identity exists or when only - caller-supplied metadata is available (fail closed). + Raises ``HTTPException`` when no API-key-bound ``user_id`` exists or when + only caller-influenceable identity fields are available (fail closed). """ trusted_id = self._resolve_trusted_user_id(data, user_api_key_dict) if trusted_id: diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py index 5f83632cfa9..9b815ec65cd 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py @@ -1803,10 +1803,11 @@ class TestResolveTrustedUserId: auth = UserAPIKeyAuth(api_key="test", user_id="auth-user-111") assert guardrail._resolve_trusted_user_id({}, auth) == "auth-user-111" - def test_trusted_user_id_from_end_user_id(self): + def test_end_user_id_not_trusted_for_blocking(self): + """end_user_id is request-derived; must not be used for blocking DLP.""" guardrail = _make_guardrail() auth = UserAPIKeyAuth(api_key="test", end_user_id="end-user-222") - assert guardrail._resolve_trusted_user_id({}, auth) == "end-user-222" + assert guardrail._resolve_trusted_user_id({}, auth) is None def test_metadata_user_api_key_user_id_not_trusted_without_auth(self): """Metadata user_api_key_user_id is not trusted when the key has no user_id.""" @@ -1906,6 +1907,15 @@ class TestResolveUserIdForBlocking: assert exc_info.value.status_code == 400 assert "bind user_id" in str(exc_info.value.detail) + def test_end_user_id_only_raises_for_blocking(self): + """Request-derived end_user_id cannot drive blocking Purview checks.""" + guardrail = _make_guardrail() + auth = UserAPIKeyAuth(api_key="test", end_user_id="caller-end-user") + with pytest.raises(HTTPException) as exc_info: + guardrail._resolve_user_id_for_blocking({}, auth) + assert exc_info.value.status_code == 400 + assert "proxy-authenticated" in str(exc_info.value.detail) + # --------------------------------------------------------------- # Token-id prompt handling in pre_call blocking mode