fix(ovalix): send the raw user identifier (empty when absent), not its hash, as the tracker actor

This commit is contained in:
Shalom Jamil 2026-07-21 12:46:22 +03:00
parent fa0d3ea14c
commit 8b7a8bf73b
2 changed files with 43 additions and 4 deletions

View file

@ -196,12 +196,12 @@ class OvalixGuardrail(CustomGuardrail):
return metadata["user_api_key_user_email"]
if metadata.get("user_api_key_user_id"):
return metadata["user_api_key_user_id"]
return "unknown"
return ""
def _get_tracker_actor_id(self, data: dict) -> str:
"""Normalize the actor string into a short, stable id for Tracker API payloads."""
# NOTE: this hash is purely for normalization — it collapses an arbitrary actor
# string (email, user id, or "unknown") into a compact, fixed-length, consistent
# string (email, user id, or empty) into a compact, fixed-length, consistent
# key. It is not a privacy/security measure and the actor value is not sensitive,
# so a plain SHA-256 (truncated) is sufficient; no salting/KDF is needed here.
actor_id = self._get_actor(data).encode()
@ -311,7 +311,7 @@ class OvalixGuardrail(CustomGuardrail):
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
routing = await self._resolve_routing(request_data)
actor = self._get_tracker_actor_id(request_data)
actor = self._get_actor(request_data)
session_id = self._get_session_id_for_application(request_data, routing.application_id)
is_response = input_type == "response"

View file

@ -590,7 +590,7 @@ class TestOvalixGuardrail:
assert guardrail._get_actor({"metadata": {"user_api_key_user_email": "a@b.com"}}) == "a@b.com"
assert guardrail._get_actor({"metadata": {"user_api_key_user_id": "uid-1"}}) == "uid-1"
assert guardrail._get_actor({"litellm_metadata": {"user_api_key_user_id": "uid-2"}}) == "uid-2"
assert guardrail._get_actor({}) == "unknown"
assert guardrail._get_actor({}) == ""
finally:
for k in _ovalix_env():
if k in os.environ:
@ -994,6 +994,45 @@ async def test_all_allow_passes_through():
assert result["texts"] == ["hi"]
@pytest.mark.asyncio
async def test_actor_sent_to_tracker_is_raw_identifier_not_hash():
g = _static_guardrail()
request_data = {"metadata": {"user_api_key_user_email": "user@example.com"}}
inputs = GenericGuardrailAPIInputs(texts=["hi"])
seen = {}
async def _post(url, headers=None, json=None):
seen["last"] = json
r = MagicMock()
r.json.return_value = _ALLOW
r.raise_for_status = MagicMock()
return r
with patch.object(g._async_handler, "post", new=_post):
await g.apply_guardrail(inputs=inputs, request_data=request_data, input_type="request", logging_obj=None)
assert seen["last"]["actor"] == "user@example.com"
assert seen["last"]["session_id"] != "user@example.com"
assert g._get_tracker_actor_id(request_data) in seen["last"]["session_id"]
@pytest.mark.asyncio
async def test_empty_user_sends_empty_actor_matching_reference():
g = _static_guardrail()
inputs = GenericGuardrailAPIInputs(texts=["hi"])
seen = {}
async def _post(url, headers=None, json=None):
seen["last"] = json
r = MagicMock()
r.json.return_value = _ALLOW
r.raise_for_status = MagicMock()
return r
with patch.object(g._async_handler, "post", new=_post):
await g.apply_guardrail(inputs=inputs, request_data={}, input_type="request", logging_obj=None)
assert seen["last"]["actor"] == ""
@pytest.mark.asyncio
async def test_tool_result_content_skipped_on_text_path():
g = _static_guardrail()