From bb614bda7e3e4cfe77e1971c1e75358984cf4a55 Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 12:00:28 -0300 Subject: [PATCH 01/24] feat: add implicit audit log usage on agent gateway --- src/sap_cloud_sdk/agentgateway/agw_client.py | 71 +++- tests/agentgateway/unit/test_agw_client.py | 354 +++++++++++++++++++ 2 files changed, 420 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 411ff4a8..ee88f1a6 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -9,6 +9,7 @@ import asyncio import logging +from datetime import datetime, timezone from typing import Callable from sap_cloud_sdk.agentgateway.config import ClientConfig @@ -35,6 +36,7 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +from sap_cloud_sdk.core.auditlog_ng import AuditClient from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics logger = logging.getLogger(__name__) @@ -106,6 +108,8 @@ def __init__( self, tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, + audit_client: AuditClient | None = None, + tenant_id: str | None = None, ): """Initialize the Agent Gateway client. @@ -114,11 +118,17 @@ def __init__( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. + audit_client: Optional audit log client. When provided, implicit + audit events are sent for list_mcp_tools and call_mcp_tool. + tenant_id: BTP tenant UUID (e.g. ``"9e0d89c9-..."``) used in audit + events. Required when audit_client is set. """ self._tenant_subdomain = tenant_subdomain self._config = config or ClientConfig() self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() + self._audit_client = audit_client + self._tenant_id = tenant_id @staticmethod def _resolve_value( @@ -151,6 +161,32 @@ def _resolve_tenant_subdomain(self) -> str: "tenant_subdomain is required for LoB agent flow.", ) + def _send_audit_event( + self, + object_id: str, + user_id: str | None = None, + ) -> None: + """Send a DataAccess audit event. Errors are logged and suppressed.""" + if self._audit_client is None or self._tenant_id is None: + return + try: + from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, + ) + + event = pb.DataAccess() + event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) + event.common.tenant_id = self._tenant_id + if user_id: + event.common.user_initiator_id = user_id + event.channel_type = "MCP" + event.channel_id = "agent-gateway" + event.object_type = "mcp-tool" + event.object_id = object_id + self._audit_client.send(event) + except Exception: + logger.debug("Failed to send audit event", exc_info=True) + @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: """Get system-scoped authentication (client_credentials flow). @@ -300,6 +336,7 @@ async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + user_id: str | None = None, ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -320,6 +357,8 @@ async def list_mcp_tools( If provided, uses user-scoped auth instead of system auth. app_tid: BTP Application Tenant ID of the subscriber. Only used for customer agents. + user_id: User identifier recorded in the audit event when an + audit_client is configured on the client. Returns: List of MCPTool objects from all MCP servers. @@ -349,9 +388,11 @@ async def list_mcp_tools( auth = await self.get_user_auth(user_token, app_tid) else: auth = await self.get_system_auth(app_tid=app_tid) - return await get_mcp_tools_customer( + tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) + self._send_audit_event("*", user_id) + return tools # LoB flow - requires tenant_subdomain if app_tid: @@ -362,9 +403,11 @@ async def list_mcp_tools( auth = await self.get_user_auth(user_token) else: auth = await self.get_system_auth() - return await get_mcp_tools_lob( + tools = await get_mcp_tools_lob( tenant, auth.access_token, self._config.timeout ) + self._send_audit_event("*", user_id) + return tools except AgentGatewaySDKError: # Re-raise SDK errors as-is @@ -446,6 +489,7 @@ async def call_mcp_tool( tool: MCPTool, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, + user_id: str | None = None, **kwargs, ) -> str: """Invoke an MCP tool. @@ -470,6 +514,8 @@ async def call_mcp_tool( for tenant-scoped token exchange. TODO: This parameter's requirement is still being clarified with the IBD team and may be removed if unnecessary. + user_id: User identifier recorded in the audit event when an + audit_client is configured on the client. **kwargs: Tool input parameters (passed directly to the tool). Returns: @@ -511,18 +557,22 @@ async def call_mcp_tool( ) auth = await self.get_system_auth(app_tid) - return await call_mcp_tool_customer( + result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) + self._send_audit_event(tool.name, user_id) + return result # LoB flow - requires user_token and tenant_subdomain if app_tid: logger.warning("app_tid parameter ignored for LoB agent flow") auth = await self.get_user_auth(user_token, app_tid) - return await call_mcp_tool_lob( + result = await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) + self._send_audit_event(tool.name, user_id) + return result except AgentGatewaySDKError: # Re-raise SDK errors as-is @@ -545,6 +595,8 @@ def _unwrap_exception_group(exc: BaseException) -> BaseException: def create_client( tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, + audit_client: AuditClient | None = None, + tenant_id: str | None = None, ) -> AgentGatewayClient: """Create an Agent Gateway client for discovering and invoking MCP tools. @@ -556,6 +608,10 @@ def create_client( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. + audit_client: Optional audit log client. When provided, implicit + audit events are sent for list_mcp_tools and call_mcp_tool. + tenant_id: BTP tenant UUID used in audit events. Required when + audit_client is set. Returns: AgentGatewayClient instance. @@ -609,4 +665,9 @@ def create_client( user_auth = await agw_client.get_user_auth(user_token="user-jwt") ``` """ - return AgentGatewayClient(tenant_subdomain=tenant_subdomain, config=config) + return AgentGatewayClient( + tenant_subdomain=tenant_subdomain, + config=config, + audit_client=audit_client, + tenant_id=tenant_id, + ) diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index b1541fa5..eaea6efc 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -15,6 +15,8 @@ AgentGatewaySDKError, ) +_TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" + # ============================================================ # Fixtures @@ -980,3 +982,355 @@ async def test_wraps_unexpected_error(self): agw_client = create_client(tenant_subdomain="my-tenant") with pytest.raises(AgentGatewaySDKError, match="Agent card discovery failed"): await agw_client.list_agent_cards() + + +# ============================================================ +# Test: _send_audit_event +# ============================================================ + + +class TestSendAuditEvent: + """Tests for _send_audit_event helper.""" + + def test_no_op_without_audit_client(self): + """_send_audit_event is a no-op when audit_client is not set.""" + agw_client = create_client(tenant_subdomain="my-tenant") + # Should not raise + agw_client._send_audit_event("tool-name", "user@example.com") + + def test_no_op_without_tenant_id(self): + """_send_audit_event is a no-op when tenant_id is not set.""" + mock_audit = MagicMock() + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=None, + ) + agw_client._send_audit_event("tool-name") + mock_audit.send.assert_not_called() + + def test_sends_data_access_event(self): + """_send_audit_event builds and sends a DataAccess event.""" + mock_audit = MagicMock() + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + agw_client._send_audit_event("my-tool", "user@example.com") + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.common.tenant_id == _TENANT_UUID + assert event.common.user_initiator_id == "user@example.com" + assert event.channel_type == "MCP" + assert event.channel_id == "agent-gateway" + assert event.object_type == "mcp-tool" + assert event.object_id == "my-tool" + + def test_sends_without_user_id(self): + """_send_audit_event omits user_initiator_id when user_id is None.""" + mock_audit = MagicMock() + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + agw_client._send_audit_event("my-tool") + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.common.user_initiator_id == "" + + def test_suppresses_send_errors(self): + """_send_audit_event does not propagate exceptions from audit client.""" + mock_audit = MagicMock() + mock_audit.send.side_effect = RuntimeError("send failed") + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + # Should not raise + agw_client._send_audit_event("my-tool") + + +# ============================================================ +# Test: list_mcp_tools audit logging +# ============================================================ + + +class TestListMcpToolsAuditLog: + """Tests that list_mcp_tools emits an audit event on success.""" + + @pytest.mark.asyncio + async def test_lob_flow_sends_audit_event(self): + """list_mcp_tools sends audit event after LoB tool discovery.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", + new_callable=AsyncMock, + return_value=[], + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.list_mcp_tools(user_id="user@example.com") + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == "*" + assert event.common.tenant_id == _TENANT_UUID + assert event.common.user_initiator_id == "user@example.com" + + @pytest.mark.asyncio + async def test_customer_flow_sends_audit_event(self): + """list_mcp_tools sends audit event after customer tool discovery.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/path/to/credentials", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", + ) as mock_load, + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_system_token_mtls", + return_value="system-token", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", + new_callable=AsyncMock, + return_value=[], + ), + ): + mock_creds = MagicMock() + mock_creds.gateway_url = "https://agw.customer.com" + mock_load.return_value = mock_creds + + agw_client = create_client( + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.list_mcp_tools() + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == "*" + + @pytest.mark.asyncio + async def test_no_audit_event_without_audit_client(self): + """list_mcp_tools does not attempt audit logging when no audit_client.""" + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", + new_callable=AsyncMock, + return_value=[], + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.AgentGatewayClient._send_audit_event" + ) as mock_send, + ): + agw_client = create_client(tenant_subdomain="my-tenant") + await agw_client.list_mcp_tools() + + mock_send.assert_called_once_with("*", None) + + @pytest.mark.asyncio + async def test_no_audit_event_on_failure(self): + """list_mcp_tools does not send audit event when tool discovery fails.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", + new_callable=AsyncMock, + side_effect=RuntimeError("network error"), + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + with pytest.raises(AgentGatewaySDKError): + await agw_client.list_mcp_tools() + + mock_audit.send.assert_not_called() + + +# ============================================================ +# Test: call_mcp_tool audit logging +# ============================================================ + + +class TestCallMcpToolAuditLog: + """Tests that call_mcp_tool emits an audit event on success.""" + + @pytest.mark.asyncio + async def test_lob_flow_sends_audit_event(self, mock_tool): + """call_mcp_tool sends audit event with tool name after LoB invocation.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("user-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_lob", + new_callable=AsyncMock, + return_value="result", + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.call_mcp_tool( + tool=mock_tool, + user_token="user-jwt", + user_id="user@example.com", + ) + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == mock_tool.name + assert event.common.tenant_id == _TENANT_UUID + assert event.common.user_initiator_id == "user@example.com" + + @pytest.mark.asyncio + async def test_customer_flow_sends_audit_event(self, mock_tool): + """call_mcp_tool sends audit event after customer flow invocation.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/path/to/credentials", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", + ) as mock_load, + patch( + "sap_cloud_sdk.agentgateway.agw_client.exchange_user_token", + return_value="exchanged-token", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_customer", + new_callable=AsyncMock, + return_value="result", + ), + ): + mock_creds = MagicMock() + mock_creds.gateway_url = "https://agw.customer.com" + mock_load.return_value = mock_creds + + agw_client = create_client( + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.call_mcp_tool( + tool=mock_tool, + user_token="user-jwt", + ) + + mock_audit.send.assert_called_once() + event = mock_audit.send.call_args[0][0] + assert event.object_id == mock_tool.name + + @pytest.mark.asyncio + async def test_no_audit_event_on_failure(self, mock_tool): + """call_mcp_tool does not send audit event when tool invocation fails.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("user-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_lob", + new_callable=AsyncMock, + side_effect=RuntimeError("invocation error"), + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + with pytest.raises(AgentGatewaySDKError): + await agw_client.call_mcp_tool( + tool=mock_tool, + user_token="user-jwt", + ) + + mock_audit.send.assert_not_called() + + @pytest.mark.asyncio + async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): + """call_mcp_tool records tool.name as the audit event object_id.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("user-token", "https://agw.example.com"), + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.call_mcp_tool_lob", + new_callable=AsyncMock, + return_value="ok", + ), + ): + agw_client = create_client( + tenant_subdomain="my-tenant", + audit_client=mock_audit, + tenant_id=_TENANT_UUID, + ) + await agw_client.call_mcp_tool(tool=mock_tool, user_token="jwt") + + event = mock_audit.send.call_args[0][0] + assert event.object_id == "test-tool" From fa3c7704f45cc72745cf8c1d9f0726edcbbe894d Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 12:59:50 -0300 Subject: [PATCH 02/24] create audit client on initialization time of the agw client --- src/sap_cloud_sdk/agentgateway/agw_client.py | 43 +++++++++++--------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index ee88f1a6..aa6d8288 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -36,8 +36,9 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +import sap_cloud_sdk.core.auditlog_ng as auditlog_ng from sap_cloud_sdk.core.auditlog_ng import AuditClient -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics, get_tenant_id logger = logging.getLogger(__name__) @@ -108,8 +109,6 @@ def __init__( self, tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, - audit_client: AuditClient | None = None, - tenant_id: str | None = None, ): """Initialize the Agent Gateway client. @@ -118,17 +117,12 @@ def __init__( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. - audit_client: Optional audit log client. When provided, implicit - audit events are sent for list_mcp_tools and call_mcp_tool. - tenant_id: BTP tenant UUID (e.g. ``"9e0d89c9-..."``) used in audit - events. Required when audit_client is set. """ self._tenant_subdomain = tenant_subdomain self._config = config or ClientConfig() self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() - self._audit_client = audit_client - self._tenant_id = tenant_id + self._audit_client: AuditClient | None = self._create_audit_client() @staticmethod def _resolve_value( @@ -161,13 +155,32 @@ def _resolve_tenant_subdomain(self) -> str: "tenant_subdomain is required for LoB agent flow.", ) + def _create_audit_client(self) -> AuditClient | None: + """Create an audit client from the LoB destination. Returns None for customer agents or on failure.""" + tenant_subdomain = ( + self._tenant_subdomain() + if callable(self._tenant_subdomain) + else self._tenant_subdomain + ) + if not tenant_subdomain: + return None + try: + return auditlog_ng.create_client( + tenant=tenant_subdomain, + _telemetry_source=Module.AGENTGATEWAY, + ) + except Exception: + logger.debug("Failed to create audit client", exc_info=True) + return None + def _send_audit_event( self, object_id: str, user_id: str | None = None, ) -> None: """Send a DataAccess audit event. Errors are logged and suppressed.""" - if self._audit_client is None or self._tenant_id is None: + tenant_id = get_tenant_id() + if self._audit_client is None or not tenant_id: return try: from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( @@ -176,7 +189,7 @@ def _send_audit_event( event = pb.DataAccess() event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) - event.common.tenant_id = self._tenant_id + event.common.tenant_id = tenant_id if user_id: event.common.user_initiator_id = user_id event.channel_type = "MCP" @@ -595,8 +608,6 @@ def _unwrap_exception_group(exc: BaseException) -> BaseException: def create_client( tenant_subdomain: str | Callable[[], str] | None = None, config: ClientConfig | None = None, - audit_client: AuditClient | None = None, - tenant_id: str | None = None, ) -> AgentGatewayClient: """Create an Agent Gateway client for discovering and invoking MCP tools. @@ -608,10 +619,6 @@ def create_client( Can be a string or a callable returning a string. Required for LoB agents, ignored for Customer agents. config: Client configuration. Uses defaults if not provided. - audit_client: Optional audit log client. When provided, implicit - audit events are sent for list_mcp_tools and call_mcp_tool. - tenant_id: BTP tenant UUID used in audit events. Required when - audit_client is set. Returns: AgentGatewayClient instance. @@ -668,6 +675,4 @@ def create_client( return AgentGatewayClient( tenant_subdomain=tenant_subdomain, config=config, - audit_client=audit_client, - tenant_id=tenant_id, ) From bd60cd30e4f63f5ea94426cd00f8bb710b736141 Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 14:57:42 -0300 Subject: [PATCH 03/24] update unit tests --- tests/agentgateway/unit/test_agw_client.py | 194 +++++++++++++-------- 1 file changed, 122 insertions(+), 72 deletions(-) diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index eaea6efc..b6822533 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -993,31 +993,43 @@ class TestSendAuditEvent: """Tests for _send_audit_event helper.""" def test_no_op_without_audit_client(self): - """_send_audit_event is a no-op when audit_client is not set.""" - agw_client = create_client(tenant_subdomain="my-tenant") + """_send_audit_event is a no-op when audit client creation fails (no tenant_subdomain).""" + agw_client = create_client() # Should not raise agw_client._send_audit_event("tool-name", "user@example.com") def test_no_op_without_tenant_id(self): - """_send_audit_event is a no-op when tenant_id is not set.""" + """_send_audit_event is a no-op when get_tenant_id returns empty string.""" mock_audit = MagicMock() - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=None, - ) - agw_client._send_audit_event("tool-name") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value="", + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + agw_client._send_audit_event("tool-name") mock_audit.send.assert_not_called() def test_sends_data_access_event(self): """_send_audit_event builds and sends a DataAccess event.""" mock_audit = MagicMock() - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) - agw_client._send_audit_event("my-tool", "user@example.com") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + agw_client._send_audit_event("my-tool", "user@example.com") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.tenant_id == _TENANT_UUID @@ -1030,12 +1042,18 @@ def test_sends_data_access_event(self): def test_sends_without_user_id(self): """_send_audit_event omits user_initiator_id when user_id is None.""" mock_audit = MagicMock() - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) - agw_client._send_audit_event("my-tool") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + agw_client._send_audit_event("my-tool") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.user_initiator_id == "" @@ -1044,13 +1062,19 @@ def test_suppresses_send_errors(self): """_send_audit_event does not propagate exceptions from audit client.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) - # Should not raise - agw_client._send_audit_event("my-tool") + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), + ): + agw_client = create_client(tenant_subdomain="my-tenant") + # Should not raise + agw_client._send_audit_event("my-tool") # ============================================================ @@ -1066,6 +1090,14 @@ async def test_lob_flow_sends_audit_event(self): """list_mcp_tools sends audit event after LoB tool discovery.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1081,11 +1113,7 @@ async def test_lob_flow_sends_audit_event(self): return_value=[], ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.list_mcp_tools(user_id="user@example.com") mock_audit.send.assert_called_once() @@ -1095,10 +1123,18 @@ async def test_lob_flow_sends_audit_event(self): assert event.common.user_initiator_id == "user@example.com" @pytest.mark.asyncio - async def test_customer_flow_sends_audit_event(self): - """list_mcp_tools sends audit event after customer tool discovery.""" + async def test_customer_flow_no_audit_event(self): + """list_mcp_tools does not send audit event for customer agents (no tenant_subdomain).""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value="/path/to/credentials", @@ -1120,19 +1156,14 @@ async def test_customer_flow_sends_audit_event(self): mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds - agw_client = create_client( - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client() await agw_client.list_mcp_tools() - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.object_id == "*" + mock_audit.send.assert_not_called() @pytest.mark.asyncio async def test_no_audit_event_without_audit_client(self): - """list_mcp_tools does not attempt audit logging when no audit_client.""" + """list_mcp_tools still calls _send_audit_event (which silently skips when no audit client).""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -1162,6 +1193,14 @@ async def test_no_audit_event_on_failure(self): """list_mcp_tools does not send audit event when tool discovery fails.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1177,11 +1216,7 @@ async def test_no_audit_event_on_failure(self): side_effect=RuntimeError("network error"), ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") with pytest.raises(AgentGatewaySDKError): await agw_client.list_mcp_tools() @@ -1201,6 +1236,14 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): """call_mcp_tool sends audit event with tool name after LoB invocation.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1216,11 +1259,7 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): return_value="result", ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.call_mcp_tool( tool=mock_tool, user_token="user-jwt", @@ -1234,10 +1273,18 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): assert event.common.user_initiator_id == "user@example.com" @pytest.mark.asyncio - async def test_customer_flow_sends_audit_event(self, mock_tool): - """call_mcp_tool sends audit event after customer flow invocation.""" + async def test_customer_flow_no_audit_event(self, mock_tool): + """call_mcp_tool does not send audit event for customer agents (no tenant_subdomain).""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value="/path/to/credentials", @@ -1259,24 +1306,27 @@ async def test_customer_flow_sends_audit_event(self, mock_tool): mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds - agw_client = create_client( - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client() await agw_client.call_mcp_tool( tool=mock_tool, user_token="user-jwt", ) - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.object_id == mock_tool.name + mock_audit.send.assert_not_called() @pytest.mark.asyncio async def test_no_audit_event_on_failure(self, mock_tool): """call_mcp_tool does not send audit event when tool invocation fails.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1292,11 +1342,7 @@ async def test_no_audit_event_on_failure(self, mock_tool): side_effect=RuntimeError("invocation error"), ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") with pytest.raises(AgentGatewaySDKError): await agw_client.call_mcp_tool( tool=mock_tool, @@ -1310,6 +1356,14 @@ async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): """call_mcp_tool records tool.name as the audit event object_id.""" mock_audit = MagicMock() with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + return_value=_TENANT_UUID, + ), patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", return_value=None, @@ -1325,11 +1379,7 @@ async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): return_value="ok", ), ): - agw_client = create_client( - tenant_subdomain="my-tenant", - audit_client=mock_audit, - tenant_id=_TENANT_UUID, - ) + agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.call_mcp_tool(tool=mock_tool, user_token="jwt") event = mock_audit.send.call_args[0][0] From f99f6b454b8aeaedf9d480778bc3541710c1a26d Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 15:46:42 -0300 Subject: [PATCH 04/24] version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3bbf3426..5f3c1189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.33.0" +version = "0.34.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From 5515beaf8d1cb37aa434b55a675503989f972b9a Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 6 Jul 2026 15:59:16 -0300 Subject: [PATCH 05/24] resolve pre-commit issue --- src/sap_cloud_sdk/agentgateway/agw_client.py | 15 +++++++++++---- tests/agentgateway/unit/test_agw_client.py | 6 +++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 9ef47e23..f18b862c 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -39,7 +39,12 @@ from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError import sap_cloud_sdk.core.auditlog_ng as auditlog_ng from sap_cloud_sdk.core.auditlog_ng import AuditClient -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics, get_tenant_id +from sap_cloud_sdk.core.telemetry import ( + Module, + Operation, + record_metrics, + get_tenant_id, +) logger = logging.getLogger(__name__) @@ -159,9 +164,11 @@ def _resolve_tenant_subdomain(self) -> str: def _create_audit_client(self) -> AuditClient | None: """Create an audit client from the LoB destination. Returns None for customer agents or on failure.""" tenant_subdomain = ( - self._tenant_subdomain() - if callable(self._tenant_subdomain) - else self._tenant_subdomain + self._tenant_subdomain + if isinstance(self._tenant_subdomain, str) + else self._tenant_subdomain() + if self._tenant_subdomain is not None + else None ) if not tenant_subdomain: return None diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 9cb1f16a..e4e0cf79 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -983,8 +983,8 @@ async def test_wraps_unexpected_error(self): with pytest.raises(AgentGatewaySDKError, match="Agent card discovery failed"): await agw_client.list_agent_cards() - -# ============================================================ + +# ============================================================ # Test: get_ias_client_id # ============================================================ @@ -1051,7 +1051,7 @@ def test_lob_raises_on_exception(self, _mock_detect): with pytest.raises(AgentGatewaySDKError, match="Could not resolve IAS client ID"): create_client(tenant_subdomain="my-tenant").get_ias_client_id() - + # ============================================================ # Test: _send_audit_event # ============================================================ From 8d5b08e10cd23429e3cc0eb81430a30955555834 Mon Sep 17 00:00:00 2001 From: Soares Date: Tue, 7 Jul 2026 15:15:52 -0300 Subject: [PATCH 06/24] move import to the top --- src/sap_cloud_sdk/agentgateway/agw_client.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index f18b862c..40ed60ab 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -45,6 +45,7 @@ record_metrics, get_tenant_id, ) +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import auditevent_pb2 as pb logger = logging.getLogger(__name__) @@ -191,10 +192,6 @@ def _send_audit_event( if self._audit_client is None or not tenant_id: return try: - from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( - auditevent_pb2 as pb, - ) - event = pb.DataAccess() event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) event.common.tenant_id = tenant_id From 02dc447d8f03e0a59a36adecd64cc1a9c7c5a7fc Mon Sep 17 00:00:00 2001 From: Soares Date: Wed, 8 Jul 2026 09:10:10 -0300 Subject: [PATCH 07/24] ruff format --- src/sap_cloud_sdk/agentgateway/agw_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 40ed60ab..4889a8b8 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -45,7 +45,9 @@ record_metrics, get_tenant_id, ) -from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import auditevent_pb2 as pb +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, +) logger = logging.getLogger(__name__) From 2c7eb754ced94d26b8c89ed8842b38d66d9cfea8 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 10 Jul 2026 11:06:35 -0300 Subject: [PATCH 08/24] move the auditlog helpers in agw to a separate .py file and update unit tests --- .../agentgateway/_auditlog_helper.py | 57 +++++++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 62 ++-------- tests/agentgateway/unit/test_agw_client.py | 110 +++++++----------- 3 files changed, 106 insertions(+), 123 deletions(-) create mode 100644 src/sap_cloud_sdk/agentgateway/_auditlog_helper.py diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py new file mode 100644 index 00000000..dc4cee6a --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -0,0 +1,57 @@ +"""Audit log helpers for the Agent Gateway client.""" + +import logging +from datetime import datetime, timezone +from typing import Callable + +import sap_cloud_sdk.core.auditlog_ng as auditlog_ng +from sap_cloud_sdk.core.auditlog_ng import AuditClient +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, +) +from sap_cloud_sdk.core.telemetry import Module, get_tenant_id + +logger = logging.getLogger(__name__) + + +def create_audit_client( + tenant_subdomain: str | Callable[[], str] | None, + module: Module, +) -> AuditClient | None: + """Create an audit client from a LoB destination. Returns None on failure.""" + resolved = tenant_subdomain() if callable(tenant_subdomain) else tenant_subdomain + if not resolved: + return None + tenant_subdomain = resolved + try: + return auditlog_ng.create_client( + tenant=tenant_subdomain, + _telemetry_source=module, + ) + except Exception: + logger.debug("Failed to create audit client", exc_info=True) + return None + + +def send_audit_event( + audit_client: AuditClient | None, + object_id: str, + user_id: str | None = None, +) -> None: + """Send a DataAccess audit event. Errors are logged and suppressed.""" + tenant_id = get_tenant_id() + if audit_client is None or not tenant_id: + return + try: + event = pb.DataAccess() + event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) + event.common.tenant_id = tenant_id + if user_id: + event.common.user_initiator_id = user_id + event.channel_type = "MCP" + event.channel_id = "agent-gateway" + event.object_type = "mcp-tool" + event.object_id = object_id + audit_client.send(event) + except Exception: + logger.debug("Failed to send audit event", exc_info=True) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 4889a8b8..6831761b 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -9,7 +9,6 @@ import asyncio import logging -from datetime import datetime, timezone from typing import Callable from sap_cloud_sdk.agentgateway.config import ClientConfig @@ -35,18 +34,14 @@ AuthResult, MCPTool, ) +from sap_cloud_sdk.agentgateway._auditlog_helper import create_audit_client, send_audit_event from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError -import sap_cloud_sdk.core.auditlog_ng as auditlog_ng from sap_cloud_sdk.core.auditlog_ng import AuditClient from sap_cloud_sdk.core.telemetry import ( Module, Operation, record_metrics, - get_tenant_id, -) -from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( - auditevent_pb2 as pb, ) logger = logging.getLogger(__name__) @@ -131,7 +126,9 @@ def __init__( self._config = config or ClientConfig() self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() - self._audit_client: AuditClient | None = self._create_audit_client() + self._audit_client: AuditClient | None = create_audit_client( + tenant_subdomain, Module.AGENTGATEWAY + ) @staticmethod def _resolve_value( @@ -164,49 +161,6 @@ def _resolve_tenant_subdomain(self) -> str: "tenant_subdomain is required for LoB agent flow.", ) - def _create_audit_client(self) -> AuditClient | None: - """Create an audit client from the LoB destination. Returns None for customer agents or on failure.""" - tenant_subdomain = ( - self._tenant_subdomain - if isinstance(self._tenant_subdomain, str) - else self._tenant_subdomain() - if self._tenant_subdomain is not None - else None - ) - if not tenant_subdomain: - return None - try: - return auditlog_ng.create_client( - tenant=tenant_subdomain, - _telemetry_source=Module.AGENTGATEWAY, - ) - except Exception: - logger.debug("Failed to create audit client", exc_info=True) - return None - - def _send_audit_event( - self, - object_id: str, - user_id: str | None = None, - ) -> None: - """Send a DataAccess audit event. Errors are logged and suppressed.""" - tenant_id = get_tenant_id() - if self._audit_client is None or not tenant_id: - return - try: - event = pb.DataAccess() - event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) - event.common.tenant_id = tenant_id - if user_id: - event.common.user_initiator_id = user_id - event.channel_type = "MCP" - event.channel_id = "agent-gateway" - event.object_type = "mcp-tool" - event.object_id = object_id - self._audit_client.send(event) - except Exception: - logger.debug("Failed to send audit event", exc_info=True) - @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: """Get system-scoped authentication (client_credentials flow). @@ -445,7 +399,7 @@ async def list_mcp_tools( tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) - self._send_audit_event("*", user_id) + send_audit_event(self._audit_client, "*", user_id) return tools # LoB flow - requires tenant_subdomain @@ -460,7 +414,7 @@ async def list_mcp_tools( tools = await get_mcp_tools_lob( tenant, auth.access_token, self._config.timeout ) - self._send_audit_event("*", user_id) + send_audit_event(self._audit_client, "*", user_id) return tools except AgentGatewaySDKError: @@ -614,7 +568,7 @@ async def call_mcp_tool( result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) - self._send_audit_event(tool.name, user_id) + send_audit_event(self._audit_client, tool.name, user_id) return result # LoB flow - requires user_token and tenant_subdomain @@ -625,7 +579,7 @@ async def call_mcp_tool( result = await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) - self._send_audit_event(tool.name, user_id) + send_audit_event(self._audit_client, tool.name, user_id) return result except AgentGatewaySDKError: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index e4e0cf79..21f23ded 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -14,6 +14,7 @@ MCPTool, AgentGatewaySDKError, ) +from sap_cloud_sdk.agentgateway._auditlog_helper import send_audit_event _TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" @@ -1058,46 +1059,31 @@ def test_lob_raises_on_exception(self, _mock_detect): class TestSendAuditEvent: - """Tests for _send_audit_event helper.""" + """Tests for send_audit_event helper.""" def test_no_op_without_audit_client(self): - """_send_audit_event is a no-op when audit client creation fails (no tenant_subdomain).""" - agw_client = create_client() + """send_audit_event is a no-op when audit_client is None.""" # Should not raise - agw_client._send_audit_event("tool-name", "user@example.com") + send_audit_event(None, "tool-name", "user@example.com") def test_no_op_without_tenant_id(self): - """_send_audit_event is a no-op when get_tenant_id returns empty string.""" + """send_audit_event is a no-op when get_tenant_id returns empty string.""" mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value="", - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value="", ): - agw_client = create_client(tenant_subdomain="my-tenant") - agw_client._send_audit_event("tool-name") + send_audit_event(mock_audit, "tool-name") mock_audit.send.assert_not_called() def test_sends_data_access_event(self): - """_send_audit_event builds and sends a DataAccess event.""" + """send_audit_event builds and sends a DataAccess event.""" mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value=_TENANT_UUID, - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, ): - agw_client = create_client(tenant_subdomain="my-tenant") - agw_client._send_audit_event("my-tool", "user@example.com") + send_audit_event(mock_audit, "my-tool", "user@example.com") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.tenant_id == _TENANT_UUID @@ -1108,41 +1094,27 @@ def test_sends_data_access_event(self): assert event.object_id == "my-tool" def test_sends_without_user_id(self): - """_send_audit_event omits user_initiator_id when user_id is None.""" + """send_audit_event omits user_initiator_id when user_id is None.""" mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value=_TENANT_UUID, - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, ): - agw_client = create_client(tenant_subdomain="my-tenant") - agw_client._send_audit_event("my-tool") + send_audit_event(mock_audit, "my-tool") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.user_initiator_id == "" def test_suppresses_send_errors(self): - """_send_audit_event does not propagate exceptions from audit client.""" + """send_audit_event does not propagate exceptions from audit client.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", - return_value=_TENANT_UUID, - ), + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, ): - agw_client = create_client(tenant_subdomain="my-tenant") # Should not raise - agw_client._send_audit_event("my-tool") + send_audit_event(mock_audit, "my-tool") # ============================================================ @@ -1159,11 +1131,11 @@ async def test_lob_flow_sends_audit_event(self): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1196,11 +1168,11 @@ async def test_customer_flow_no_audit_event(self): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1231,7 +1203,7 @@ async def test_customer_flow_no_audit_event(self): @pytest.mark.asyncio async def test_no_audit_event_without_audit_client(self): - """list_mcp_tools still calls _send_audit_event (which silently skips when no audit client).""" + """list_mcp_tools still calls send_audit_event (which silently skips when no audit client).""" with ( patch( "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", @@ -1248,13 +1220,13 @@ async def test_no_audit_event_without_audit_client(self): return_value=[], ), patch( - "sap_cloud_sdk.agentgateway.agw_client.AgentGatewayClient._send_audit_event" + "sap_cloud_sdk.agentgateway.agw_client.send_audit_event" ) as mock_send, ): agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.list_mcp_tools() - mock_send.assert_called_once_with("*", None) + mock_send.assert_called_once_with(agw_client._audit_client, "*", None) @pytest.mark.asyncio async def test_no_audit_event_on_failure(self): @@ -1262,11 +1234,11 @@ async def test_no_audit_event_on_failure(self): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1305,11 +1277,11 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1346,11 +1318,11 @@ async def test_customer_flow_no_audit_event(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1388,11 +1360,11 @@ async def test_no_audit_event_on_failure(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1425,11 +1397,11 @@ async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway.agw_client.auditlog_ng.create_client", + "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_tenant_id", + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( From f7d74b81311e0e3d740eefbd19ba781ed55e0e08 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 10 Jul 2026 11:41:27 -0300 Subject: [PATCH 09/24] created AuditLogMode config to control audit logging failure behavior --- .../agentgateway/_auditlog_helper.py | 20 ++++++++--- src/sap_cloud_sdk/agentgateway/agw_client.py | 6 ++-- src/sap_cloud_sdk/agentgateway/config.py | 19 +++++++++++ tests/agentgateway/unit/test_agw_client.py | 33 ++++++++++++++++--- 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index dc4cee6a..462f9fb2 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -5,6 +5,7 @@ from typing import Callable import sap_cloud_sdk.core.auditlog_ng as auditlog_ng +from sap_cloud_sdk.agentgateway.config import AuditLogMode from sap_cloud_sdk.core.auditlog_ng import AuditClient from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( auditevent_pb2 as pb, @@ -17,19 +18,23 @@ def create_audit_client( tenant_subdomain: str | Callable[[], str] | None, module: Module, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> AuditClient | None: """Create an audit client from a LoB destination. Returns None on failure.""" + if mode is AuditLogMode.DISABLED: + return None resolved = tenant_subdomain() if callable(tenant_subdomain) else tenant_subdomain if not resolved: return None - tenant_subdomain = resolved try: return auditlog_ng.create_client( - tenant=tenant_subdomain, + tenant=resolved, _telemetry_source=module, ) except Exception: - logger.debug("Failed to create audit client", exc_info=True) + if mode is AuditLogMode.STRICT: + raise + logger.warning("Failed to create audit client — audit events will not be recorded", exc_info=True) return None @@ -37,8 +42,11 @@ def send_audit_event( audit_client: AuditClient | None, object_id: str, user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> None: - """Send a DataAccess audit event. Errors are logged and suppressed.""" + """Send a DataAccess audit event.""" + if mode is AuditLogMode.DISABLED: + return tenant_id = get_tenant_id() if audit_client is None or not tenant_id: return @@ -54,4 +62,6 @@ def send_audit_event( event.object_id = object_id audit_client.send(event) except Exception: - logger.debug("Failed to send audit event", exc_info=True) + if mode is AuditLogMode.STRICT: + raise + logger.warning("Failed to send audit event", exc_info=True) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 6831761b..31c2c829 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -127,7 +127,7 @@ def __init__( self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() self._audit_client: AuditClient | None = create_audit_client( - tenant_subdomain, Module.AGENTGATEWAY + tenant_subdomain, Module.AGENTGATEWAY, self._config.audit_log_mode ) @staticmethod @@ -399,7 +399,7 @@ async def list_mcp_tools( tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) - send_audit_event(self._audit_client, "*", user_id) + send_audit_event(self._audit_client, "*", user_id, self._config.audit_log_mode) return tools # LoB flow - requires tenant_subdomain @@ -568,7 +568,7 @@ async def call_mcp_tool( result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) - send_audit_event(self._audit_client, tool.name, user_id) + send_audit_event(self._audit_client, tool.name, user_id, self._config.audit_log_mode) return result # LoB flow - requires user_token and tenant_subdomain diff --git a/src/sap_cloud_sdk/agentgateway/config.py b/src/sap_cloud_sdk/agentgateway/config.py index 17495dbd..3184e55c 100644 --- a/src/sap_cloud_sdk/agentgateway/config.py +++ b/src/sap_cloud_sdk/agentgateway/config.py @@ -1,6 +1,7 @@ """Configuration for Agent Gateway client.""" from dataclasses import dataclass +from enum import Enum DEFAULT_TIMEOUT_SECONDS = 60.0 DEFAULT_FALLBACK_TOKEN_TTL_SECONDS = 300.0 @@ -9,6 +10,21 @@ DEFAULT_MAX_USER_TOKEN_CACHE_SIZE = 256 +class AuditLogMode(Enum): + """Controls how audit logging failures are handled. + + Attributes: + DISABLED: Audit logging is skipped entirely. + BEST_EFFORT: Failures are logged at WARNING level but never raised. + This is the default. + STRICT: Failures raise an exception, blocking the operation. + """ + + DISABLED = "disabled" + BEST_EFFORT = "best_effort" + STRICT = "strict" + + @dataclass class ClientConfig: """Configuration options for the Agent Gateway client. @@ -22,6 +38,8 @@ class ClientConfig: token expiries before a cached token is considered stale. max_system_token_cache_size: Maximum number of cached system tokens. max_user_token_cache_size: Maximum number of cached user tokens. + audit_log_mode: Controls how audit logging failures are handled. + Defaults to BEST_EFFORT. """ timeout: float = DEFAULT_TIMEOUT_SECONDS @@ -29,6 +47,7 @@ class ClientConfig: token_expiry_buffer_seconds: float = DEFAULT_TOKEN_EXPIRY_BUFFER_SECONDS max_system_token_cache_size: int = DEFAULT_MAX_SYSTEM_TOKEN_CACHE_SIZE max_user_token_cache_size: int = DEFAULT_MAX_USER_TOKEN_CACHE_SIZE + audit_log_mode: AuditLogMode = AuditLogMode.BEST_EFFORT def __post_init__(self) -> None: if self.token_expiry_buffer_seconds >= self.fallback_token_ttl_seconds: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 21f23ded..e4db8169 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -15,6 +15,7 @@ AgentGatewaySDKError, ) from sap_cloud_sdk.agentgateway._auditlog_helper import send_audit_event +from sap_cloud_sdk.agentgateway.config import AuditLogMode, ClientConfig _TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" @@ -1054,7 +1055,7 @@ def test_lob_raises_on_exception(self, _mock_detect): # ============================================================ -# Test: _send_audit_event +# Test: send_audit_event # ============================================================ @@ -1105,16 +1106,38 @@ def test_sends_without_user_id(self): event = mock_audit.send.call_args[0][0] assert event.common.user_initiator_id == "" - def test_suppresses_send_errors(self): - """send_audit_event does not propagate exceptions from audit client.""" + def test_best_effort_suppresses_send_errors(self): + """send_audit_event does not propagate exceptions in BEST_EFFORT mode.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") with patch( "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ): - # Should not raise - send_audit_event(mock_audit, "my-tool") + send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.BEST_EFFORT) + + def test_strict_raises_on_send_error(self): + """send_audit_event raises in STRICT mode when send fails.""" + mock_audit = MagicMock() + mock_audit.send.side_effect = RuntimeError("send failed") + with ( + patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ), + pytest.raises(RuntimeError, match="send failed"), + ): + send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.STRICT) + + def test_disabled_skips_send(self): + """send_audit_event does nothing in DISABLED mode.""" + mock_audit = MagicMock() + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ): + send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) + mock_audit.send.assert_not_called() # ============================================================ From c16ac448bc566ef51c1d7cd50a38462ca77e7c53 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 10 Jul 2026 14:10:11 -0300 Subject: [PATCH 10/24] keep auditlogging only on call_mcp_tool() --- src/sap_cloud_sdk/agentgateway/agw_client.py | 5 - tests/agentgateway/unit/test_agw_client.py | 145 ------------------- 2 files changed, 150 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 31c2c829..a6480355 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -344,7 +344,6 @@ async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, - user_id: str | None = None, ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -365,8 +364,6 @@ async def list_mcp_tools( If provided, uses user-scoped auth instead of system auth. app_tid: BTP Application Tenant ID of the subscriber. Only used for customer agents. - user_id: User identifier recorded in the audit event when an - audit_client is configured on the client. Returns: List of MCPTool objects from all MCP servers. @@ -399,7 +396,6 @@ async def list_mcp_tools( tools = await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout ) - send_audit_event(self._audit_client, "*", user_id, self._config.audit_log_mode) return tools # LoB flow - requires tenant_subdomain @@ -414,7 +410,6 @@ async def list_mcp_tools( tools = await get_mcp_tools_lob( tenant, auth.access_token, self._config.timeout ) - send_audit_event(self._audit_client, "*", user_id) return tools except AgentGatewaySDKError: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index e4db8169..9086ef46 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -1140,151 +1140,6 @@ def test_disabled_skips_send(self): mock_audit.send.assert_not_called() -# ============================================================ -# Test: list_mcp_tools audit logging -# ============================================================ - - -class TestListMcpToolsAuditLog: - """Tests that list_mcp_tools emits an audit event on success.""" - - @pytest.mark.asyncio - async def test_lob_flow_sends_audit_event(self): - """list_mcp_tools sends audit event after LoB tool discovery.""" - mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", - return_value=_TENANT_UUID, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", - new_callable=AsyncMock, - return_value=("token", "https://agw.example.com"), - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", - new_callable=AsyncMock, - return_value=[], - ), - ): - agw_client = create_client(tenant_subdomain="my-tenant") - await agw_client.list_mcp_tools(user_id="user@example.com") - - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.object_id == "*" - assert event.common.tenant_id == _TENANT_UUID - assert event.common.user_initiator_id == "user@example.com" - - @pytest.mark.asyncio - async def test_customer_flow_no_audit_event(self): - """list_mcp_tools does not send audit event for customer agents (no tenant_subdomain).""" - mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", - return_value=_TENANT_UUID, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value="/path/to/credentials", - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", - ) as mock_load, - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_system_token_mtls", - return_value="system-token", - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", - new_callable=AsyncMock, - return_value=[], - ), - ): - mock_creds = MagicMock() - mock_creds.gateway_url = "https://agw.customer.com" - mock_load.return_value = mock_creds - - agw_client = create_client() - await agw_client.list_mcp_tools() - - mock_audit.send.assert_not_called() - - @pytest.mark.asyncio - async def test_no_audit_event_without_audit_client(self): - """list_mcp_tools still calls send_audit_event (which silently skips when no audit client).""" - with ( - patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", - new_callable=AsyncMock, - return_value=("token", "https://agw.example.com"), - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", - new_callable=AsyncMock, - return_value=[], - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.send_audit_event" - ) as mock_send, - ): - agw_client = create_client(tenant_subdomain="my-tenant") - await agw_client.list_mcp_tools() - - mock_send.assert_called_once_with(agw_client._audit_client, "*", None) - - @pytest.mark.asyncio - async def test_no_audit_event_on_failure(self): - """list_mcp_tools does not send audit event when tool discovery fails.""" - mock_audit = MagicMock() - with ( - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", - return_value=mock_audit, - ), - patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", - return_value=_TENANT_UUID, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", - new_callable=AsyncMock, - return_value=("token", "https://agw.example.com"), - ), - patch( - "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_lob", - new_callable=AsyncMock, - side_effect=RuntimeError("network error"), - ), - ): - agw_client = create_client(tenant_subdomain="my-tenant") - with pytest.raises(AgentGatewaySDKError): - await agw_client.list_mcp_tools() - - mock_audit.send.assert_not_called() - # ============================================================ # Test: call_mcp_tool audit logging From 3c916a1a2126f3ec703dca09040b9e098eaea4b4 Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 13 Jul 2026 09:15:18 -0300 Subject: [PATCH 11/24] add justifying comments to suppressed audit log exception --- src/sap_cloud_sdk/agentgateway/_auditlog_helper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index 462f9fb2..fe2c5eca 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -34,6 +34,7 @@ def create_audit_client( except Exception: if mode is AuditLogMode.STRICT: raise + # BEST_EFFORT: suppress and warn — audit failure must never break the main flow logger.warning("Failed to create audit client — audit events will not be recorded", exc_info=True) return None @@ -64,4 +65,5 @@ def send_audit_event( except Exception: if mode is AuditLogMode.STRICT: raise + # BEST_EFFORT: suppress and warn — audit failure must never break the main flow logger.warning("Failed to send audit event", exc_info=True) From d21d0e6c326512f4ff75f6febd3ce60394881bf8 Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 13 Jul 2026 09:23:09 -0300 Subject: [PATCH 12/24] fix ty type errors in create_audit_client and apply ruff format --- src/sap_cloud_sdk/agentgateway/_auditlog_helper.py | 12 ++++++++++-- src/sap_cloud_sdk/agentgateway/agw_client.py | 9 +++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index fe2c5eca..5a7d1f13 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -23,7 +23,12 @@ def create_audit_client( """Create an audit client from a LoB destination. Returns None on failure.""" if mode is AuditLogMode.DISABLED: return None - resolved = tenant_subdomain() if callable(tenant_subdomain) else tenant_subdomain + if isinstance(tenant_subdomain, str): + resolved: str | None = tenant_subdomain + elif tenant_subdomain is not None: + resolved = tenant_subdomain() + else: + resolved = None if not resolved: return None try: @@ -35,7 +40,10 @@ def create_audit_client( if mode is AuditLogMode.STRICT: raise # BEST_EFFORT: suppress and warn — audit failure must never break the main flow - logger.warning("Failed to create audit client — audit events will not be recorded", exc_info=True) + logger.warning( + "Failed to create audit client — audit events will not be recorded", + exc_info=True, + ) return None diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index db357b7a..ca9623e3 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -34,7 +34,10 @@ AuthResult, MCPTool, ) -from sap_cloud_sdk.agentgateway._auditlog_helper import create_audit_client, send_audit_event +from sap_cloud_sdk.agentgateway._auditlog_helper import ( + create_audit_client, + send_audit_event, +) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError from sap_cloud_sdk.core.auditlog_ng import AuditClient @@ -566,7 +569,9 @@ async def call_mcp_tool( result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) - send_audit_event(self._audit_client, tool.name, user_id, self._config.audit_log_mode) + send_audit_event( + self._audit_client, tool.name, user_id, self._config.audit_log_mode + ) return result # LoB flow - requires user_token and tenant_subdomain From 5f1b07014d7865b600f59c65ac5439c5ca790830 Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 13 Jul 2026 14:00:24 -0300 Subject: [PATCH 13/24] emit MCP_TOOL_INVOKED/COMPLETED/FAILED audit lifecycle events on call_mcp_tool --- .../agentgateway/_auditlog_helper.py | 114 +++++++++++--- src/sap_cloud_sdk/agentgateway/agw_client.py | 32 +++- tests/agentgateway/unit/test_agw_client.py | 146 +++++++++++++----- 3 files changed, 222 insertions(+), 70 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index 5a7d1f13..1a79bab3 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -10,10 +10,15 @@ from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( auditevent_pb2 as pb, ) +from google.protobuf.struct_pb2 import Struct, Value from sap_cloud_sdk.core.telemetry import Module, get_tenant_id logger = logging.getLogger(__name__) +MCP_TOOL_INVOKED = "MCP_TOOL_INVOKED" +MCP_TOOL_COMPLETED = "MCP_TOOL_COMPLETED" +MCP_TOOL_FAILED = "MCP_TOOL_FAILED" + def create_audit_client( tenant_subdomain: str | Callable[[], str] | None, @@ -47,31 +52,98 @@ def create_audit_client( return None -def send_audit_event( - audit_client: AuditClient | None, - object_id: str, - user_id: str | None = None, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +def _build_custom_event( + event_name: str, + tool_name: str, + tenant_id: str, + user_id: str | None, + extra: dict | None = None, +) -> pb.ZzzCustomEvent: + common = pb.Common() + common.timestamp.FromDatetime(datetime.now(timezone.utc)) + common.tenant_id = tenant_id + common.app_context["event_name"] = event_name + if user_id: + common.user_initiator_id = user_id + + payload: dict = {"event_name": event_name, "tool": tool_name} + if extra: + payload.update(extra) + custom_struct = Struct() + custom_struct.update(payload) + + event = pb.ZzzCustomEvent() + event.common.CopyFrom(common) + event.custom.CopyFrom(Value(struct_value=custom_struct)) + return event + + +def _send( + audit_client: AuditClient, + event: pb.ZzzCustomEvent, + mode: AuditLogMode, ) -> None: - """Send a DataAccess audit event.""" - if mode is AuditLogMode.DISABLED: - return - tenant_id = get_tenant_id() - if audit_client is None or not tenant_id: - return try: - event = pb.DataAccess() - event.common.timestamp.FromDatetime(datetime.now(timezone.utc)) - event.common.tenant_id = tenant_id - if user_id: - event.common.user_initiator_id = user_id - event.channel_type = "MCP" - event.channel_id = "agent-gateway" - event.object_type = "mcp-tool" - event.object_id = object_id audit_client.send(event) except Exception: if mode is AuditLogMode.STRICT: raise - # BEST_EFFORT: suppress and warn — audit failure must never break the main flow + # BEST_EFFORT: suppress and warn — audit failure does not break the main agw flow logger.warning("Failed to send audit event", exc_info=True) + + +def _guard( + audit_client: AuditClient | None, + mode: AuditLogMode, +) -> tuple[str, bool]: + """Return (tenant_id, should_skip). Skip when disabled, no client, or no tenant.""" + if mode is AuditLogMode.DISABLED: + return "", True + tenant_id = get_tenant_id() + if audit_client is None or not tenant_id: + return "", True + return tenant_id, False + + +def send_audit_event_invoked( + audit_client: AuditClient | None, + tool_name: str, + user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> None: + """Emit MCP_TOOL_INVOKED before the tool call starts.""" + tenant_id, skip = _guard(audit_client, mode) + if skip: + return + _send(audit_client, _build_custom_event(MCP_TOOL_INVOKED, tool_name, tenant_id, user_id), mode) # type: ignore[arg-type] + + +def send_audit_event_completed( + audit_client: AuditClient | None, + tool_name: str, + user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> None: + """Emit MCP_TOOL_COMPLETED after the tool call succeeds.""" + tenant_id, skip = _guard(audit_client, mode) + if skip: + return + _send(audit_client, _build_custom_event(MCP_TOOL_COMPLETED, tool_name, tenant_id, user_id), mode) # type: ignore[arg-type] + + +def send_audit_event_failed( + audit_client: AuditClient | None, + tool_name: str, + error_type: str, + user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> None: + """Emit MCP_TOOL_FAILED when the tool call raises an exception.""" + tenant_id, skip = _guard(audit_client, mode) + if skip: + return + _send( + audit_client, # type: ignore[arg-type] + _build_custom_event(MCP_TOOL_FAILED, tool_name, tenant_id, user_id, extra={"error_type": error_type}), + mode, + ) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index ca9623e3..ad04a283 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -36,7 +36,9 @@ ) from sap_cloud_sdk.agentgateway._auditlog_helper import ( create_audit_client, - send_audit_event, + send_audit_event_invoked, + send_audit_event_completed, + send_audit_event_failed, ) from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError @@ -566,10 +568,19 @@ async def call_mcp_tool( ) auth = await self.get_system_auth(app_tid) - result = await call_mcp_tool_customer( - tool, auth.access_token, self._config.timeout, **kwargs + send_audit_event_invoked( + self._audit_client, tool.name, user_id, self._config.audit_log_mode ) - send_audit_event( + try: + result = await call_mcp_tool_customer( + tool, auth.access_token, self._config.timeout, **kwargs + ) + except Exception as e: + send_audit_event_failed( + self._audit_client, tool.name, type(e).__name__, user_id, self._config.audit_log_mode + ) + raise + send_audit_event_completed( self._audit_client, tool.name, user_id, self._config.audit_log_mode ) return result @@ -579,10 +590,15 @@ async def call_mcp_tool( logger.warning("app_tid parameter ignored for LoB agent flow") auth = await self.get_user_auth(user_token, app_tid) - result = await call_mcp_tool_lob( - tool, auth.access_token, self._config.timeout, **kwargs - ) - send_audit_event(self._audit_client, tool.name, user_id) + send_audit_event_invoked(self._audit_client, tool.name, user_id) + try: + result = await call_mcp_tool_lob( + tool, auth.access_token, self._config.timeout, **kwargs + ) + except Exception as e: + send_audit_event_failed(self._audit_client, tool.name, type(e).__name__, user_id) + raise + send_audit_event_completed(self._audit_client, tool.name, user_id) return result except AgentGatewaySDKError: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 921ce6c9..e2d234eb 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -15,7 +15,14 @@ AgentGatewaySDKError, ) -from sap_cloud_sdk.agentgateway._auditlog_helper import send_audit_event +from sap_cloud_sdk.agentgateway._auditlog_helper import ( + send_audit_event_invoked, + send_audit_event_completed, + send_audit_event_failed, + MCP_TOOL_INVOKED, + MCP_TOOL_COMPLETED, + MCP_TOOL_FAILED, +) from sap_cloud_sdk.agentgateway.config import AuditLogMode, ClientConfig from sap_cloud_sdk.core.telemetry import Module @@ -1077,69 +1084,64 @@ def test_lob_raises_on_exception(self, _mock_detect): # ============================================================ -# Test: send_audit_event +# Test: send_audit_event_invoked / completed / failed # ============================================================ -class TestSendAuditEvent: - """Tests for send_audit_event helper.""" +class TestSendAuditEventInvoked: + """Tests for send_audit_event_invoked helper.""" def test_no_op_without_audit_client(self): - """send_audit_event is a no-op when audit_client is None.""" - # Should not raise - send_audit_event(None, "tool-name", "user@example.com") + """send_audit_event_invoked is a no-op when audit_client is None.""" + send_audit_event_invoked(None, "tool-name", "user@example.com") def test_no_op_without_tenant_id(self): - """send_audit_event is a no-op when get_tenant_id returns empty string.""" + """send_audit_event_invoked is a no-op when get_tenant_id returns empty string.""" mock_audit = MagicMock() with patch( "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value="", ): - send_audit_event(mock_audit, "tool-name") + send_audit_event_invoked(mock_audit, "tool-name") mock_audit.send.assert_not_called() - def test_sends_data_access_event(self): - """send_audit_event builds and sends a DataAccess event.""" + def test_sends_invoked_event(self): + """send_audit_event_invoked builds and sends a ZzzCustomEvent with MCP_TOOL_INVOKED.""" mock_audit = MagicMock() with patch( "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ): - send_audit_event(mock_audit, "my-tool", "user@example.com") + send_audit_event_invoked(mock_audit, "my-tool", "user@example.com") mock_audit.send.assert_called_once() event = mock_audit.send.call_args[0][0] assert event.common.tenant_id == _TENANT_UUID assert event.common.user_initiator_id == "user@example.com" - assert event.channel_type == "MCP" - assert event.channel_id == "agent-gateway" - assert event.object_type == "mcp-tool" - assert event.object_id == "my-tool" + assert event.common.app_context["event_name"] == MCP_TOOL_INVOKED def test_sends_without_user_id(self): - """send_audit_event omits user_initiator_id when user_id is None.""" + """send_audit_event_invoked omits user_initiator_id when user_id is None.""" mock_audit = MagicMock() with patch( "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ): - send_audit_event(mock_audit, "my-tool") - mock_audit.send.assert_called_once() + send_audit_event_invoked(mock_audit, "my-tool") event = mock_audit.send.call_args[0][0] assert event.common.user_initiator_id == "" def test_best_effort_suppresses_send_errors(self): - """send_audit_event does not propagate exceptions in BEST_EFFORT mode.""" + """send_audit_event_invoked does not propagate exceptions in BEST_EFFORT mode.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") with patch( "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ): - send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.BEST_EFFORT) + send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.BEST_EFFORT) def test_strict_raises_on_send_error(self): - """send_audit_event raises in STRICT mode when send fails.""" + """send_audit_event_invoked raises in STRICT mode when send fails.""" mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") with ( @@ -1149,16 +1151,72 @@ def test_strict_raises_on_send_error(self): ), pytest.raises(RuntimeError, match="send failed"), ): - send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.STRICT) + send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.STRICT) + + def test_disabled_skips_send(self): + """send_audit_event_invoked does nothing in DISABLED mode.""" + mock_audit = MagicMock() + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ): + send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) + mock_audit.send.assert_not_called() + + +class TestSendAuditEventCompleted: + """Tests for send_audit_event_completed helper.""" + + def test_sends_completed_event(self): + """send_audit_event_completed sends a ZzzCustomEvent with MCP_TOOL_COMPLETED.""" + mock_audit = MagicMock() + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ): + send_audit_event_completed(mock_audit, "my-tool", "user@example.com") + event = mock_audit.send.call_args[0][0] + assert event.common.app_context["event_name"] == MCP_TOOL_COMPLETED + assert event.common.tenant_id == _TENANT_UUID + + def test_no_op_without_audit_client(self): + send_audit_event_completed(None, "tool-name") + + def test_disabled_skips_send(self): + mock_audit = MagicMock() + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ): + send_audit_event_completed(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) + mock_audit.send.assert_not_called() + + +class TestSendAuditEventFailed: + """Tests for send_audit_event_failed helper.""" + + def test_sends_failed_event_with_error_type(self): + """send_audit_event_failed sends MCP_TOOL_FAILED with error_type in payload.""" + mock_audit = MagicMock() + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value=_TENANT_UUID, + ): + send_audit_event_failed(mock_audit, "my-tool", "ValueError", "user@example.com") + event = mock_audit.send.call_args[0][0] + assert event.common.app_context["event_name"] == MCP_TOOL_FAILED + assert event.common.tenant_id == _TENANT_UUID + + def test_no_op_without_audit_client(self): + send_audit_event_failed(None, "tool-name", "RuntimeError") def test_disabled_skips_send(self): - """send_audit_event does nothing in DISABLED mode.""" mock_audit = MagicMock() with patch( "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", return_value=_TENANT_UUID, ): - send_audit_event(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) + send_audit_event_failed(mock_audit, "my-tool", "RuntimeError", mode=AuditLogMode.DISABLED) mock_audit.send.assert_not_called() @@ -1169,11 +1227,11 @@ def test_disabled_skips_send(self): class TestCallMcpToolAuditLog: - """Tests that call_mcp_tool emits an audit event on success.""" + """Tests that call_mcp_tool emits audit events for the full tool lifecycle.""" @pytest.mark.asyncio - async def test_lob_flow_sends_audit_event(self, mock_tool): - """call_mcp_tool sends audit event with tool name after LoB invocation.""" + async def test_lob_flow_sends_invoked_and_completed(self, mock_tool): + """call_mcp_tool sends MCP_TOOL_INVOKED then MCP_TOOL_COMPLETED on success.""" mock_audit = MagicMock() with ( patch( @@ -1206,15 +1264,17 @@ async def test_lob_flow_sends_audit_event(self, mock_tool): user_id="user@example.com", ) - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.object_id == mock_tool.name - assert event.common.tenant_id == _TENANT_UUID - assert event.common.user_initiator_id == "user@example.com" + assert mock_audit.send.call_count == 2 + invoked_event = mock_audit.send.call_args_list[0][0][0] + completed_event = mock_audit.send.call_args_list[1][0][0] + assert invoked_event.common.app_context["event_name"] == "MCP_TOOL_INVOKED" + assert invoked_event.common.tenant_id == _TENANT_UUID + assert invoked_event.common.user_initiator_id == "user@example.com" + assert completed_event.common.app_context["event_name"] == "MCP_TOOL_COMPLETED" @pytest.mark.asyncio async def test_customer_flow_no_audit_event(self, mock_tool): - """call_mcp_tool does not send audit event for customer agents (no tenant_subdomain).""" + """call_mcp_tool does not send audit events for customer agents (no tenant_subdomain).""" mock_audit = MagicMock() with ( patch( @@ -1255,8 +1315,8 @@ async def test_customer_flow_no_audit_event(self, mock_tool): mock_audit.send.assert_not_called() @pytest.mark.asyncio - async def test_no_audit_event_on_failure(self, mock_tool): - """call_mcp_tool does not send audit event when tool invocation fails.""" + async def test_lob_flow_sends_invoked_and_failed_on_error(self, mock_tool): + """call_mcp_tool sends MCP_TOOL_INVOKED then MCP_TOOL_FAILED when tool raises.""" mock_audit = MagicMock() with ( patch( @@ -1289,11 +1349,15 @@ async def test_no_audit_event_on_failure(self, mock_tool): user_token="user-jwt", ) - mock_audit.send.assert_not_called() + assert mock_audit.send.call_count == 2 + invoked_event = mock_audit.send.call_args_list[0][0][0] + failed_event = mock_audit.send.call_args_list[1][0][0] + assert invoked_event.common.app_context["event_name"] == "MCP_TOOL_INVOKED" + assert failed_event.common.app_context["event_name"] == "MCP_TOOL_FAILED" @pytest.mark.asyncio - async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): - """call_mcp_tool records tool.name as the audit event object_id.""" + async def test_audit_event_uses_tool_name(self, mock_tool): + """call_mcp_tool stamps tool.name in the invoked audit event payload.""" mock_audit = MagicMock() with ( patch( @@ -1322,5 +1386,5 @@ async def test_audit_event_uses_tool_name_as_object_id(self, mock_tool): agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.call_mcp_tool(tool=mock_tool, user_token="jwt") - event = mock_audit.send.call_args[0][0] - assert event.object_id == "test-tool" + invoked_event = mock_audit.send.call_args_list[0][0][0] + assert invoked_event.custom.struct_value.fields["tool"].string_value == "test-tool" From 867afd5ff3220f723263fe3c553bf63f53426f3c Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 13 Jul 2026 14:08:11 -0300 Subject: [PATCH 14/24] ruff format --- .../agentgateway/_auditlog_helper.py | 31 +++++++++++++------ src/sap_cloud_sdk/agentgateway/agw_client.py | 10 ++++-- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index 1a79bab3..171e9d06 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -10,7 +10,6 @@ from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( auditevent_pb2 as pb, ) -from google.protobuf.struct_pb2 import Struct, Value from sap_cloud_sdk.core.telemetry import Module, get_tenant_id logger = logging.getLogger(__name__) @@ -69,20 +68,20 @@ def _build_custom_event( payload: dict = {"event_name": event_name, "tool": tool_name} if extra: payload.update(extra) - custom_struct = Struct() - custom_struct.update(payload) event = pb.ZzzCustomEvent() event.common.CopyFrom(common) - event.custom.CopyFrom(Value(struct_value=custom_struct)) + event.custom.struct_value.update(payload) return event def _send( - audit_client: AuditClient, + audit_client: AuditClient | None, event: pb.ZzzCustomEvent, mode: AuditLogMode, ) -> None: + if audit_client is None: + return try: audit_client.send(event) except Exception: @@ -115,7 +114,11 @@ def send_audit_event_invoked( tenant_id, skip = _guard(audit_client, mode) if skip: return - _send(audit_client, _build_custom_event(MCP_TOOL_INVOKED, tool_name, tenant_id, user_id), mode) # type: ignore[arg-type] + _send( + audit_client, + _build_custom_event(MCP_TOOL_INVOKED, tool_name, tenant_id, user_id), + mode, + ) def send_audit_event_completed( @@ -128,7 +131,11 @@ def send_audit_event_completed( tenant_id, skip = _guard(audit_client, mode) if skip: return - _send(audit_client, _build_custom_event(MCP_TOOL_COMPLETED, tool_name, tenant_id, user_id), mode) # type: ignore[arg-type] + _send( + audit_client, + _build_custom_event(MCP_TOOL_COMPLETED, tool_name, tenant_id, user_id), + mode, + ) def send_audit_event_failed( @@ -143,7 +150,13 @@ def send_audit_event_failed( if skip: return _send( - audit_client, # type: ignore[arg-type] - _build_custom_event(MCP_TOOL_FAILED, tool_name, tenant_id, user_id, extra={"error_type": error_type}), + audit_client, + _build_custom_event( + MCP_TOOL_FAILED, + tool_name, + tenant_id, + user_id, + extra={"error_type": error_type}, + ), mode, ) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index ad04a283..bbc6df5b 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -577,7 +577,11 @@ async def call_mcp_tool( ) except Exception as e: send_audit_event_failed( - self._audit_client, tool.name, type(e).__name__, user_id, self._config.audit_log_mode + self._audit_client, + tool.name, + type(e).__name__, + user_id, + self._config.audit_log_mode, ) raise send_audit_event_completed( @@ -596,7 +600,9 @@ async def call_mcp_tool( tool, auth.access_token, self._config.timeout, **kwargs ) except Exception as e: - send_audit_event_failed(self._audit_client, tool.name, type(e).__name__, user_id) + send_audit_event_failed( + self._audit_client, tool.name, type(e).__name__, user_id + ) raise send_audit_event_completed(self._audit_client, tool.name, user_id) return result From e9f42f22cc66a52d0f3a630af3b1f525c01954ff Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 13 Jul 2026 15:08:22 -0300 Subject: [PATCH 15/24] simplify send auditlog logic --- .../agentgateway/_auditlog_helper.py | 59 ++++--------------- 1 file changed, 13 insertions(+), 46 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index 171e9d06..656611d4 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -43,7 +43,7 @@ def create_audit_client( except Exception: if mode is AuditLogMode.STRICT: raise - # BEST_EFFORT: suppress and warn — audit failure must never break the main flow + # BEST_EFFORT: suppress and warn — audit failure does not break the main agw flow logger.warning( "Failed to create audit client — audit events will not be recorded", exc_info=True, @@ -77,11 +77,18 @@ def _build_custom_event( def _send( audit_client: AuditClient | None, - event: pb.ZzzCustomEvent, + event_name: str, + tool_name: str, + user_id: str | None, mode: AuditLogMode, + extra: dict | None = None, ) -> None: - if audit_client is None: + if mode is AuditLogMode.DISABLED: return + tenant_id = get_tenant_id() + if audit_client is None or not tenant_id: + return + event = _build_custom_event(event_name, tool_name, tenant_id, user_id, extra) try: audit_client.send(event) except Exception: @@ -91,19 +98,6 @@ def _send( logger.warning("Failed to send audit event", exc_info=True) -def _guard( - audit_client: AuditClient | None, - mode: AuditLogMode, -) -> tuple[str, bool]: - """Return (tenant_id, should_skip). Skip when disabled, no client, or no tenant.""" - if mode is AuditLogMode.DISABLED: - return "", True - tenant_id = get_tenant_id() - if audit_client is None or not tenant_id: - return "", True - return tenant_id, False - - def send_audit_event_invoked( audit_client: AuditClient | None, tool_name: str, @@ -111,14 +105,7 @@ def send_audit_event_invoked( mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> None: """Emit MCP_TOOL_INVOKED before the tool call starts.""" - tenant_id, skip = _guard(audit_client, mode) - if skip: - return - _send( - audit_client, - _build_custom_event(MCP_TOOL_INVOKED, tool_name, tenant_id, user_id), - mode, - ) + _send(audit_client, MCP_TOOL_INVOKED, tool_name, user_id, mode) def send_audit_event_completed( @@ -128,14 +115,7 @@ def send_audit_event_completed( mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> None: """Emit MCP_TOOL_COMPLETED after the tool call succeeds.""" - tenant_id, skip = _guard(audit_client, mode) - if skip: - return - _send( - audit_client, - _build_custom_event(MCP_TOOL_COMPLETED, tool_name, tenant_id, user_id), - mode, - ) + _send(audit_client, MCP_TOOL_COMPLETED, tool_name, user_id, mode) def send_audit_event_failed( @@ -146,17 +126,4 @@ def send_audit_event_failed( mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> None: """Emit MCP_TOOL_FAILED when the tool call raises an exception.""" - tenant_id, skip = _guard(audit_client, mode) - if skip: - return - _send( - audit_client, - _build_custom_event( - MCP_TOOL_FAILED, - tool_name, - tenant_id, - user_id, - extra={"error_type": error_type}, - ), - mode, - ) + _send(audit_client, MCP_TOOL_FAILED, tool_name, user_id, mode, extra={"error_type": error_type}) From cec0904c35a403115fd953549f8bbaa0f6bc2bee Mon Sep 17 00:00:00 2001 From: Soares Date: Mon, 13 Jul 2026 15:09:42 -0300 Subject: [PATCH 16/24] apply ruff format --- src/sap_cloud_sdk/agentgateway/_auditlog_helper.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index 656611d4..71f37f06 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -126,4 +126,11 @@ def send_audit_event_failed( mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> None: """Emit MCP_TOOL_FAILED when the tool call raises an exception.""" - _send(audit_client, MCP_TOOL_FAILED, tool_name, user_id, mode, extra={"error_type": error_type}) + _send( + audit_client, + MCP_TOOL_FAILED, + tool_name, + user_id, + mode, + extra={"error_type": error_type}, + ) From 069eac8907674560d490701a23590b6e4a77812d Mon Sep 17 00:00:00 2001 From: Soares Date: Thu, 16 Jul 2026 09:54:44 -0300 Subject: [PATCH 17/24] extract the resolve tenant logic to a separate method --- .../agentgateway/_auditlog_helper.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py index 71f37f06..4f6db863 100644 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -19,6 +19,14 @@ MCP_TOOL_FAILED = "MCP_TOOL_FAILED" +def _resolve_tenant(tenant_subdomain: str | Callable[[], str] | None) -> str | None: + if isinstance(tenant_subdomain, str): + return tenant_subdomain + if tenant_subdomain is not None: + return tenant_subdomain() + return None + + def create_audit_client( tenant_subdomain: str | Callable[[], str] | None, module: Module, @@ -27,12 +35,7 @@ def create_audit_client( """Create an audit client from a LoB destination. Returns None on failure.""" if mode is AuditLogMode.DISABLED: return None - if isinstance(tenant_subdomain, str): - resolved: str | None = tenant_subdomain - elif tenant_subdomain is not None: - resolved = tenant_subdomain() - else: - resolved = None + resolved = _resolve_tenant(tenant_subdomain) if not resolved: return None try: From f3050208baecc86857220000611196a40d097663 Mon Sep 17 00:00:00 2001 From: Soares Date: Thu, 16 Jul 2026 15:43:28 -0300 Subject: [PATCH 18/24] refactor implicit auditlog on agw to use generic core helpers --- .../agentgateway/_auditlog_helper.py | 139 ---------------- .../agentgateway/_implicit_auditlog.py | 60 +++++++ src/sap_cloud_sdk/agentgateway/agw_client.py | 2 +- src/sap_cloud_sdk/agentgateway/config.py | 20 +-- .../core/auditlog_ng/__init__.py | 9 ++ src/sap_cloud_sdk/core/auditlog_ng/helper.py | 148 ++++++++++++++++++ tests/agentgateway/unit/test_agw_client.py | 38 ++--- .../auditlog_ng/unit/test_custom_event.py | 55 +++++++ 8 files changed, 296 insertions(+), 175 deletions(-) delete mode 100644 src/sap_cloud_sdk/agentgateway/_auditlog_helper.py create mode 100644 src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py create mode 100644 src/sap_cloud_sdk/core/auditlog_ng/helper.py create mode 100644 tests/core/unit/auditlog_ng/unit/test_custom_event.py diff --git a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py deleted file mode 100644 index 4f6db863..00000000 --- a/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Audit log helpers for the Agent Gateway client.""" - -import logging -from datetime import datetime, timezone -from typing import Callable - -import sap_cloud_sdk.core.auditlog_ng as auditlog_ng -from sap_cloud_sdk.agentgateway.config import AuditLogMode -from sap_cloud_sdk.core.auditlog_ng import AuditClient -from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( - auditevent_pb2 as pb, -) -from sap_cloud_sdk.core.telemetry import Module, get_tenant_id - -logger = logging.getLogger(__name__) - -MCP_TOOL_INVOKED = "MCP_TOOL_INVOKED" -MCP_TOOL_COMPLETED = "MCP_TOOL_COMPLETED" -MCP_TOOL_FAILED = "MCP_TOOL_FAILED" - - -def _resolve_tenant(tenant_subdomain: str | Callable[[], str] | None) -> str | None: - if isinstance(tenant_subdomain, str): - return tenant_subdomain - if tenant_subdomain is not None: - return tenant_subdomain() - return None - - -def create_audit_client( - tenant_subdomain: str | Callable[[], str] | None, - module: Module, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, -) -> AuditClient | None: - """Create an audit client from a LoB destination. Returns None on failure.""" - if mode is AuditLogMode.DISABLED: - return None - resolved = _resolve_tenant(tenant_subdomain) - if not resolved: - return None - try: - return auditlog_ng.create_client( - tenant=resolved, - _telemetry_source=module, - ) - except Exception: - if mode is AuditLogMode.STRICT: - raise - # BEST_EFFORT: suppress and warn — audit failure does not break the main agw flow - logger.warning( - "Failed to create audit client — audit events will not be recorded", - exc_info=True, - ) - return None - - -def _build_custom_event( - event_name: str, - tool_name: str, - tenant_id: str, - user_id: str | None, - extra: dict | None = None, -) -> pb.ZzzCustomEvent: - common = pb.Common() - common.timestamp.FromDatetime(datetime.now(timezone.utc)) - common.tenant_id = tenant_id - common.app_context["event_name"] = event_name - if user_id: - common.user_initiator_id = user_id - - payload: dict = {"event_name": event_name, "tool": tool_name} - if extra: - payload.update(extra) - - event = pb.ZzzCustomEvent() - event.common.CopyFrom(common) - event.custom.struct_value.update(payload) - return event - - -def _send( - audit_client: AuditClient | None, - event_name: str, - tool_name: str, - user_id: str | None, - mode: AuditLogMode, - extra: dict | None = None, -) -> None: - if mode is AuditLogMode.DISABLED: - return - tenant_id = get_tenant_id() - if audit_client is None or not tenant_id: - return - event = _build_custom_event(event_name, tool_name, tenant_id, user_id, extra) - try: - audit_client.send(event) - except Exception: - if mode is AuditLogMode.STRICT: - raise - # BEST_EFFORT: suppress and warn — audit failure does not break the main agw flow - logger.warning("Failed to send audit event", exc_info=True) - - -def send_audit_event_invoked( - audit_client: AuditClient | None, - tool_name: str, - user_id: str | None = None, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, -) -> None: - """Emit MCP_TOOL_INVOKED before the tool call starts.""" - _send(audit_client, MCP_TOOL_INVOKED, tool_name, user_id, mode) - - -def send_audit_event_completed( - audit_client: AuditClient | None, - tool_name: str, - user_id: str | None = None, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, -) -> None: - """Emit MCP_TOOL_COMPLETED after the tool call succeeds.""" - _send(audit_client, MCP_TOOL_COMPLETED, tool_name, user_id, mode) - - -def send_audit_event_failed( - audit_client: AuditClient | None, - tool_name: str, - error_type: str, - user_id: str | None = None, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, -) -> None: - """Emit MCP_TOOL_FAILED when the tool call raises an exception.""" - _send( - audit_client, - MCP_TOOL_FAILED, - tool_name, - user_id, - mode, - extra={"error_type": error_type}, - ) diff --git a/src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py b/src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py new file mode 100644 index 00000000..457ba101 --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py @@ -0,0 +1,60 @@ +"""Audit log helpers for the Agent Gateway client.""" + +from sap_cloud_sdk.core.auditlog_ng import AuditClient +from sap_cloud_sdk.core.auditlog_ng.helper import ( + AuditLogMode, + create_audit_client, + send_event, +) + +__all__ = [ + "AuditLogMode", + "create_audit_client", + "MCP_TOOL_INVOKED", + "MCP_TOOL_COMPLETED", + "MCP_TOOL_FAILED", + "send_audit_event_invoked", + "send_audit_event_completed", + "send_audit_event_failed", +] + +MCP_TOOL_INVOKED = "MCP_TOOL_INVOKED" +MCP_TOOL_COMPLETED = "MCP_TOOL_COMPLETED" +MCP_TOOL_FAILED = "MCP_TOOL_FAILED" + + +def send_audit_event_invoked( + audit_client: AuditClient | None, + tool_name: str, + user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> None: + """Emit MCP_TOOL_INVOKED before the tool call starts.""" + send_event(audit_client, MCP_TOOL_INVOKED, {"tool": tool_name}, user_id, mode) + + +def send_audit_event_completed( + audit_client: AuditClient | None, + tool_name: str, + user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> None: + """Emit MCP_TOOL_COMPLETED after the tool call succeeds.""" + send_event(audit_client, MCP_TOOL_COMPLETED, {"tool": tool_name}, user_id, mode) + + +def send_audit_event_failed( + audit_client: AuditClient | None, + tool_name: str, + error_type: str, + user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> None: + """Emit MCP_TOOL_FAILED when the tool call raises an exception.""" + send_event( + audit_client, + MCP_TOOL_FAILED, + {"tool": tool_name, "error_type": error_type}, + user_id, + mode, + ) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index bbc6df5b..8c125fd8 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -34,7 +34,7 @@ AuthResult, MCPTool, ) -from sap_cloud_sdk.agentgateway._auditlog_helper import ( +from sap_cloud_sdk.agentgateway._implicit_auditlog import ( create_audit_client, send_audit_event_invoked, send_audit_event_completed, diff --git a/src/sap_cloud_sdk/agentgateway/config.py b/src/sap_cloud_sdk/agentgateway/config.py index 3184e55c..9f81d45b 100644 --- a/src/sap_cloud_sdk/agentgateway/config.py +++ b/src/sap_cloud_sdk/agentgateway/config.py @@ -1,7 +1,10 @@ """Configuration for Agent Gateway client.""" from dataclasses import dataclass -from enum import Enum + +from sap_cloud_sdk.core.auditlog_ng.helper import AuditLogMode + +__all__ = ["AuditLogMode", "ClientConfig"] DEFAULT_TIMEOUT_SECONDS = 60.0 DEFAULT_FALLBACK_TOKEN_TTL_SECONDS = 300.0 @@ -10,21 +13,6 @@ DEFAULT_MAX_USER_TOKEN_CACHE_SIZE = 256 -class AuditLogMode(Enum): - """Controls how audit logging failures are handled. - - Attributes: - DISABLED: Audit logging is skipped entirely. - BEST_EFFORT: Failures are logged at WARNING level but never raised. - This is the default. - STRICT: Failures raise an exception, blocking the operation. - """ - - DISABLED = "disabled" - BEST_EFFORT = "best_effort" - STRICT = "strict" - - @dataclass class ClientConfig: """Configuration options for the Agent Gateway client. diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index c262b608..4717b950 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -56,6 +56,11 @@ AuditLogNGConfig, SCHEMA_URL, ) +from sap_cloud_sdk.core.auditlog_ng.helper import ( + AuditLogMode, + create_audit_client, + send_event, +) from sap_cloud_sdk.core.auditlog_ng.exceptions import ( AuditLogNGError, ClientCreationError, @@ -295,6 +300,10 @@ def create_client( "create_client", # Client "AuditClient", + # Audit helpers + "AuditLogMode", + "create_audit_client", + "send_event", # Configuration "AuditLogNGConfig", # Exceptions diff --git a/src/sap_cloud_sdk/core/auditlog_ng/helper.py b/src/sap_cloud_sdk/core/auditlog_ng/helper.py new file mode 100644 index 00000000..75370a87 --- /dev/null +++ b/src/sap_cloud_sdk/core/auditlog_ng/helper.py @@ -0,0 +1,148 @@ +"""Audit log helper for custom events. + +Provides managed client creation and a ``ZzzCustomEvent`` send pattern — +the SAP Audit Log v2 event type for application-defined audit events, +which are not covered by the standard catalog. + +For standard catalog events (``DataAccess``, ``ConfigurationChange``, +``UserLoginSuccess``, etc.), construct the protobuf directly and call +``AuditClient.send()``. +""" + +import logging +from datetime import datetime, timezone +from enum import Enum +from typing import Callable + +import sap_cloud_sdk.core.auditlog_ng as auditlog_ng +from sap_cloud_sdk.core.auditlog_ng.client import AuditClient +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, +) +from sap_cloud_sdk.core.telemetry import Module, get_tenant_id + +logger = logging.getLogger(__name__) + + +class AuditLogMode(Enum): + """Controls how audit logging failures are handled. + + Attributes: + DISABLED: Audit logging is skipped entirely. + BEST_EFFORT: Failures are logged at WARNING level but never raised. + This is the default. + STRICT: Failures raise an exception, blocking the operation. + """ + + DISABLED = "disabled" + BEST_EFFORT = "best_effort" + STRICT = "strict" + + +def _emit_custom_event( + audit_client: AuditClient, + tenant_id: str, + event_name: str, + payload: dict, + user_id: str | None = None, +) -> None: + """Build and send a ZzzCustomEvent to the audit log service. + + Args: + audit_client: Initialized AuditClient instance. + tenant_id: Tenant UUID stamped on the event. + event_name: Event identifier (e.g. ``"MCP_TOOL_INVOKED"``). + payload: Arbitrary key/value pairs serialized into the custom struct. + ``event_name`` is always included automatically. + user_id: Optional user initiator ID stamped on the event. + """ + common = pb.Common() + common.timestamp.FromDatetime(datetime.now(timezone.utc)) + common.tenant_id = tenant_id + common.app_context["event_name"] = event_name + if user_id: + common.user_initiator_id = user_id + + event = pb.ZzzCustomEvent() + event.common.CopyFrom(common) + event.custom.struct_value.update({"event_name": event_name, **payload}) + audit_client.send(event) + + +def _resolve_tenant(tenant_subdomain: str | Callable[[], str] | None) -> str | None: + if isinstance(tenant_subdomain, str): + return tenant_subdomain + if callable(tenant_subdomain): + return tenant_subdomain() + return None + + +def create_audit_client( + tenant_subdomain: str | Callable[[], str] | None, + module: Module, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> AuditClient | None: + """Create an audit client from a LoB destination. + + Args: + tenant_subdomain: Tenant subdomain string or callable returning one. + module: Telemetry source module identifier. + mode: Controls failure handling. Returns None when DISABLED or when + tenant is not resolvable. + + Returns: + Initialized AuditClient, or None on failure / when disabled. + """ + if mode is AuditLogMode.DISABLED: + return None + resolved = _resolve_tenant(tenant_subdomain) + if not resolved: + return None + try: + return auditlog_ng.create_client( + tenant=resolved, + _telemetry_source=module, + ) + except Exception: + if mode is AuditLogMode.STRICT: + raise + # BEST_EFFORT: suppress the error and warn instead of propagating + logger.warning( + "Failed to create audit client — audit events will not be recorded", + exc_info=True, + ) + return None + + +def send_event( + audit_client: AuditClient | None, + event_name: str, + payload: dict, + user_id: str | None = None, + mode: AuditLogMode = AuditLogMode.BEST_EFFORT, +) -> None: + """Send a ZzzCustomEvent to the audit log service. + + Resolves the tenant ID via ``get_tenant_id()`` and applies ``AuditLogMode`` + semantics around the send. No-op when disabled, client is None, or tenant + is not resolvable. + + Args: + audit_client: Initialized AuditClient, or None to skip. + event_name: Event identifier stamped on the event (e.g. ``"MCP_TOOL_INVOKED"``). + payload: Arbitrary key/value pairs included in the custom struct. + user_id: Optional user initiator ID. + mode: Controls failure handling. + """ + if mode is AuditLogMode.DISABLED: + return + tenant_id = get_tenant_id() + if audit_client is None or not tenant_id: + return + try: + _emit_custom_event(audit_client, tenant_id, event_name, payload, user_id) + except Exception: + if mode is AuditLogMode.STRICT: + raise + # BEST_EFFORT: supress the error and warn instead of propagating + logger.warning("Failed to send audit event", exc_info=True) diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index e2d234eb..b03c4e2b 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -15,7 +15,7 @@ AgentGatewaySDKError, ) -from sap_cloud_sdk.agentgateway._auditlog_helper import ( +from sap_cloud_sdk.agentgateway._implicit_auditlog import ( send_audit_event_invoked, send_audit_event_completed, send_audit_event_failed, @@ -1099,7 +1099,7 @@ def test_no_op_without_tenant_id(self): """send_audit_event_invoked is a no-op when get_tenant_id returns empty string.""" mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value="", ): send_audit_event_invoked(mock_audit, "tool-name") @@ -1109,7 +1109,7 @@ def test_sends_invoked_event(self): """send_audit_event_invoked builds and sends a ZzzCustomEvent with MCP_TOOL_INVOKED.""" mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_invoked(mock_audit, "my-tool", "user@example.com") @@ -1123,7 +1123,7 @@ def test_sends_without_user_id(self): """send_audit_event_invoked omits user_initiator_id when user_id is None.""" mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_invoked(mock_audit, "my-tool") @@ -1135,7 +1135,7 @@ def test_best_effort_suppresses_send_errors(self): mock_audit = MagicMock() mock_audit.send.side_effect = RuntimeError("send failed") with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.BEST_EFFORT) @@ -1146,7 +1146,7 @@ def test_strict_raises_on_send_error(self): mock_audit.send.side_effect = RuntimeError("send failed") with ( patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ), pytest.raises(RuntimeError, match="send failed"), @@ -1157,7 +1157,7 @@ def test_disabled_skips_send(self): """send_audit_event_invoked does nothing in DISABLED mode.""" mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) @@ -1171,7 +1171,7 @@ def test_sends_completed_event(self): """send_audit_event_completed sends a ZzzCustomEvent with MCP_TOOL_COMPLETED.""" mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_completed(mock_audit, "my-tool", "user@example.com") @@ -1185,7 +1185,7 @@ def test_no_op_without_audit_client(self): def test_disabled_skips_send(self): mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_completed(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) @@ -1199,7 +1199,7 @@ def test_sends_failed_event_with_error_type(self): """send_audit_event_failed sends MCP_TOOL_FAILED with error_type in payload.""" mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_failed(mock_audit, "my-tool", "ValueError", "user@example.com") @@ -1213,7 +1213,7 @@ def test_no_op_without_audit_client(self): def test_disabled_skips_send(self): mock_audit = MagicMock() with patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ): send_audit_event_failed(mock_audit, "my-tool", "RuntimeError", mode=AuditLogMode.DISABLED) @@ -1235,11 +1235,11 @@ async def test_lob_flow_sends_invoked_and_completed(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1278,11 +1278,11 @@ async def test_customer_flow_no_audit_event(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1320,11 +1320,11 @@ async def test_lob_flow_sends_invoked_and_failed_on_error(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1361,11 +1361,11 @@ async def test_audit_event_uses_tool_name(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( diff --git a/tests/core/unit/auditlog_ng/unit/test_custom_event.py b/tests/core/unit/auditlog_ng/unit/test_custom_event.py new file mode 100644 index 00000000..e3f98abe --- /dev/null +++ b/tests/core/unit/auditlog_ng/unit/test_custom_event.py @@ -0,0 +1,55 @@ +"""Unit tests for send_custom_event.""" + +from unittest.mock import MagicMock + +from sap_cloud_sdk.core.auditlog_ng.helper import _emit_custom_event as send_custom_event +from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( + auditevent_pb2 as pb, +) + +_TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" + + +class TestSendCustomEvent: + def test_sends_zzz_custom_event(self): + """send_custom_event builds and sends a ZzzCustomEvent.""" + mock_client = MagicMock() + send_custom_event(mock_client, _TENANT_UUID, "MY_EVENT", {"key": "val"}) + mock_client.send.assert_called_once() + event = mock_client.send.call_args[0][0] + assert isinstance(event, pb.ZzzCustomEvent) + assert event.common.tenant_id == _TENANT_UUID + assert event.common.app_context["event_name"] == "MY_EVENT" + + def test_payload_includes_event_name_and_custom_keys(self): + """send_custom_event merges event_name and caller payload into custom struct.""" + mock_client = MagicMock() + send_custom_event(mock_client, _TENANT_UUID, "MY_EVENT", {"tool": "my-tool"}) + event = mock_client.send.call_args[0][0] + fields = event.custom.struct_value.fields + assert fields["event_name"].string_value == "MY_EVENT" + assert fields["tool"].string_value == "my-tool" + + def test_sets_user_initiator_id_when_provided(self): + """send_custom_event stamps user_id on common.user_initiator_id.""" + mock_client = MagicMock() + send_custom_event(mock_client, _TENANT_UUID, "MY_EVENT", {}, user_id="user@example.com") + event = mock_client.send.call_args[0][0] + assert event.common.user_initiator_id == "user@example.com" + + def test_omits_user_initiator_id_when_none(self): + """send_custom_event leaves user_initiator_id empty when user_id is None.""" + mock_client = MagicMock() + send_custom_event(mock_client, _TENANT_UUID, "MY_EVENT", {}) + event = mock_client.send.call_args[0][0] + assert event.common.user_initiator_id == "" + + def test_propagates_send_exception(self): + """send_custom_event does not suppress exceptions from client.send.""" + mock_client = MagicMock() + mock_client.send.side_effect = RuntimeError("send failed") + try: + send_custom_event(mock_client, _TENANT_UUID, "MY_EVENT", {}) + assert False, "Expected RuntimeError" + except RuntimeError: + pass From 051c5fe2c853d097d04f1c6bd8ecc20a6bf94611 Mon Sep 17 00:00:00 2001 From: Soares Date: Thu, 16 Jul 2026 15:53:15 -0300 Subject: [PATCH 19/24] update user guide --- src/sap_cloud_sdk/agentgateway/user-guide.md | 35 +++++++++++++ src/sap_cloud_sdk/core/auditlog_ng/helper.py | 2 +- .../core/auditlog_ng/user-guide.md | 50 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 686d9f37..c39c5ba9 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -193,9 +193,44 @@ agw_client = create_client(tenant_subdomain="my-tenant", config=config) - `token_expiry_buffer_seconds`: Safety buffer subtracted from explicit token expiries before a cached token is reused. Default: `30.0`. - `max_system_token_cache_size`: Maximum number of cached system tokens per client instance. Default: `32`. - `max_user_token_cache_size`: Maximum number of cached exchanged user tokens per client instance. Default: `256`. +- `audit_log_mode`: Controls how audit logging failures are handled. Default: `AuditLogMode.BEST_EFFORT`. The SDK keeps token caches per `AgentGatewayClient` instance and reuses valid cached tokens for repeated authentication calls. System and user token caches are bounded independently with least-recently-used eviction. +## Implicit Audit Logging + +The SDK automatically emits audit log events for every MCP tool invocation when the client is created with a `tenant_subdomain` (LoB flow). No additional configuration is required. + +Three events are emitted per invocation: + +| Event | When | +|---|---| +| `MCP_TOOL_INVOKED` | Before the tool call starts | +| `MCP_TOOL_COMPLETED` | After the tool call succeeds | +| `MCP_TOOL_FAILED` | When the tool call raises an exception (includes `error_type`) | + +Events are sent as `ZzzCustomEvent` to the SAP Audit Log Service v3 via the `AuditLogV3_Destination` destination. + +### AuditLogMode + +Use `AuditLogMode` in `ClientConfig` to control failure handling: + +```python +from sap_cloud_sdk.agentgateway import ClientConfig, create_client +from sap_cloud_sdk.agentgateway.config import AuditLogMode + +config = ClientConfig(audit_log_mode=AuditLogMode.STRICT) +agw_client = create_client(tenant_subdomain="my-tenant", config=config) +``` + +| Mode | Behavior | +|---|---| +| `BEST_EFFORT` | Audit failures are logged as warnings and never raised. Default. | +| `STRICT` | Audit failures raise an exception, blocking the operation. | +| `DISABLED` | Audit logging is skipped entirely. | + +Audit logging is only active for LoB agents — Customer agents do not resolve a tenant subdomain at construction time and therefore emit no audit events. + ### AgentGatewayClient ```python diff --git a/src/sap_cloud_sdk/core/auditlog_ng/helper.py b/src/sap_cloud_sdk/core/auditlog_ng/helper.py index 75370a87..189577f8 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/helper.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/helper.py @@ -1,7 +1,7 @@ """Audit log helper for custom events. Provides managed client creation and a ``ZzzCustomEvent`` send pattern — -the SAP Audit Log v2 event type for application-defined audit events, +the SAP Audit Log v2 event type for application-defined audit events, which are not covered by the standard catalog. For standard catalog events (``DataAccess``, ``ConfigurationChange``, diff --git a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md index f6d58427..c0e1ed0e 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md +++ b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md @@ -284,6 +284,56 @@ Events are validated against protobuf constraints using `protovalidate` before s --- +## Sending Custom Events (ZzzCustomEvent) + +For application-defined events that have no typed protobuf equivalent, use `send_event` — a convenience wrapper around `ZzzCustomEvent`. For standard catalog events (`DataAccess`, `ConfigurationChange`, etc.), construct the protobuf directly and call `client.send()` as shown above. + +### Managed pattern (implicit audit logging) + +`send_event` resolves the tenant ID via `get_tenant_id()`, applies `AuditLogMode` semantics, and is a no-op when the client is `None` or no tenant is available — making it safe to call unconditionally: + +```python +from sap_cloud_sdk.core.auditlog_ng import create_audit_client, send_event, AuditLogMode +from sap_cloud_sdk.core.telemetry import Module + +client = create_audit_client( + tenant_subdomain="my-tenant", + module=Module.AGENTGATEWAY, + mode=AuditLogMode.BEST_EFFORT, +) + +send_event( + audit_client=client, + event_name="MY_CUSTOM_EVENT", + payload={"resource": "order-123", "action": "processed"}, + user_id="user@example.com", + mode=AuditLogMode.BEST_EFFORT, +) +``` + +### AuditLogMode + +| Mode | Behavior | +|---|---| +| `BEST_EFFORT` | Failures are logged as warnings and never raised. Default. | +| `STRICT` | Failures raise an exception, blocking the operation. | +| `DISABLED` | Audit logging is skipped entirely. | + +### create_audit_client + +`create_audit_client` wraps `create_client` with mode and tenant resolution handling. Returns `None` when disabled or when the tenant subdomain is not resolvable — making it safe to store as an optional field: + +```python +client = create_audit_client( + tenant_subdomain=lambda: get_current_tenant(), # callable also accepted + module=Module.AGENTGATEWAY, + mode=AuditLogMode.BEST_EFFORT, +) +# client is None when tenant is not resolvable or mode is DISABLED +``` + +--- + ## Running the Unit Tests ```bash From dd2b7afcd0f7e3276772b05e3831cd786a6063d6 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 17 Jul 2026 11:44:27 -0300 Subject: [PATCH 20/24] remove _implicit_auditlog wrapper, call send_event directly on agw_client with McpToolEvent enum --- .../agentgateway/_audit_events.py | 9 + .../agentgateway/_implicit_auditlog.py | 60 ------- src/sap_cloud_sdk/agentgateway/agw_client.py | 31 ++-- src/sap_cloud_sdk/agentgateway/config.py | 2 +- .../core/auditlog_ng/__init__.py | 2 +- .../{helper.py => cross_module_helper.py} | 8 +- tests/agentgateway/unit/test_agw_client.py | 162 +----------------- .../auditlog_ng/unit/test_custom_event.py | 2 +- 8 files changed, 39 insertions(+), 237 deletions(-) create mode 100644 src/sap_cloud_sdk/agentgateway/_audit_events.py delete mode 100644 src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py rename src/sap_cloud_sdk/core/auditlog_ng/{helper.py => cross_module_helper.py} (96%) diff --git a/src/sap_cloud_sdk/agentgateway/_audit_events.py b/src/sap_cloud_sdk/agentgateway/_audit_events.py new file mode 100644 index 00000000..f0b87671 --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_audit_events.py @@ -0,0 +1,9 @@ +"""Audit event name constants for Agent Gateway.""" + +from enum import StrEnum + + +class McpToolEvent(StrEnum): + INVOKED = "MCP_TOOL_INVOKED" + COMPLETED = "MCP_TOOL_COMPLETED" + FAILED = "MCP_TOOL_FAILED" diff --git a/src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py b/src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py deleted file mode 100644 index 457ba101..00000000 --- a/src/sap_cloud_sdk/agentgateway/_implicit_auditlog.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Audit log helpers for the Agent Gateway client.""" - -from sap_cloud_sdk.core.auditlog_ng import AuditClient -from sap_cloud_sdk.core.auditlog_ng.helper import ( - AuditLogMode, - create_audit_client, - send_event, -) - -__all__ = [ - "AuditLogMode", - "create_audit_client", - "MCP_TOOL_INVOKED", - "MCP_TOOL_COMPLETED", - "MCP_TOOL_FAILED", - "send_audit_event_invoked", - "send_audit_event_completed", - "send_audit_event_failed", -] - -MCP_TOOL_INVOKED = "MCP_TOOL_INVOKED" -MCP_TOOL_COMPLETED = "MCP_TOOL_COMPLETED" -MCP_TOOL_FAILED = "MCP_TOOL_FAILED" - - -def send_audit_event_invoked( - audit_client: AuditClient | None, - tool_name: str, - user_id: str | None = None, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, -) -> None: - """Emit MCP_TOOL_INVOKED before the tool call starts.""" - send_event(audit_client, MCP_TOOL_INVOKED, {"tool": tool_name}, user_id, mode) - - -def send_audit_event_completed( - audit_client: AuditClient | None, - tool_name: str, - user_id: str | None = None, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, -) -> None: - """Emit MCP_TOOL_COMPLETED after the tool call succeeds.""" - send_event(audit_client, MCP_TOOL_COMPLETED, {"tool": tool_name}, user_id, mode) - - -def send_audit_event_failed( - audit_client: AuditClient | None, - tool_name: str, - error_type: str, - user_id: str | None = None, - mode: AuditLogMode = AuditLogMode.BEST_EFFORT, -) -> None: - """Emit MCP_TOOL_FAILED when the tool call raises an exception.""" - send_event( - audit_client, - MCP_TOOL_FAILED, - {"tool": tool_name, "error_type": error_type}, - user_id, - mode, - ) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 8c125fd8..4ade6886 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -34,15 +34,14 @@ AuthResult, MCPTool, ) -from sap_cloud_sdk.agentgateway._implicit_auditlog import ( +from sap_cloud_sdk.core.auditlog_ng import AuditClient +from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import ( create_audit_client, - send_audit_event_invoked, - send_audit_event_completed, - send_audit_event_failed, + send_event as _send_audit_event, ) +from sap_cloud_sdk.agentgateway._audit_events import McpToolEvent from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError -from sap_cloud_sdk.core.auditlog_ng import AuditClient from sap_cloud_sdk.core.telemetry import ( Module, Operation, @@ -568,24 +567,24 @@ async def call_mcp_tool( ) auth = await self.get_system_auth(app_tid) - send_audit_event_invoked( - self._audit_client, tool.name, user_id, self._config.audit_log_mode + _send_audit_event( + self._audit_client, McpToolEvent.INVOKED, {"tool": tool.name}, user_id, self._config.audit_log_mode ) try: result = await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) except Exception as e: - send_audit_event_failed( + _send_audit_event( self._audit_client, - tool.name, - type(e).__name__, + McpToolEvent.FAILED, + {"tool": tool.name, "error_type": type(e).__name__}, user_id, self._config.audit_log_mode, ) raise - send_audit_event_completed( - self._audit_client, tool.name, user_id, self._config.audit_log_mode + _send_audit_event( + self._audit_client, McpToolEvent.COMPLETED, {"tool": tool.name}, user_id, self._config.audit_log_mode ) return result @@ -594,17 +593,17 @@ async def call_mcp_tool( logger.warning("app_tid parameter ignored for LoB agent flow") auth = await self.get_user_auth(user_token, app_tid) - send_audit_event_invoked(self._audit_client, tool.name, user_id) + _send_audit_event(self._audit_client, McpToolEvent.INVOKED, {"tool": tool.name}, user_id) try: result = await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) except Exception as e: - send_audit_event_failed( - self._audit_client, tool.name, type(e).__name__, user_id + _send_audit_event( + self._audit_client, McpToolEvent.FAILED, {"tool": tool.name, "error_type": type(e).__name__}, user_id ) raise - send_audit_event_completed(self._audit_client, tool.name, user_id) + _send_audit_event(self._audit_client, McpToolEvent.COMPLETED, {"tool": tool.name}, user_id) return result except AgentGatewaySDKError: diff --git a/src/sap_cloud_sdk/agentgateway/config.py b/src/sap_cloud_sdk/agentgateway/config.py index 9f81d45b..959424c4 100644 --- a/src/sap_cloud_sdk/agentgateway/config.py +++ b/src/sap_cloud_sdk/agentgateway/config.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from sap_cloud_sdk.core.auditlog_ng.helper import AuditLogMode +from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import AuditLogMode __all__ = ["AuditLogMode", "ClientConfig"] diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 4717b950..278d3977 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -56,7 +56,7 @@ AuditLogNGConfig, SCHEMA_URL, ) -from sap_cloud_sdk.core.auditlog_ng.helper import ( +from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import ( AuditLogMode, create_audit_client, send_event, diff --git a/src/sap_cloud_sdk/core/auditlog_ng/helper.py b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py similarity index 96% rename from src/sap_cloud_sdk/core/auditlog_ng/helper.py rename to src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py index 189577f8..ed5398f1 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/helper.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py @@ -1,8 +1,8 @@ -"""Audit log helper for custom events. +"""Implicit audit log helpers shared across SDK modules. -Provides managed client creation and a ``ZzzCustomEvent`` send pattern — -the SAP Audit Log v2 event type for application-defined audit events, -which are not covered by the standard catalog. +Provides managed client creation and a ``ZzzCustomEvent`` send pattern for +modules that emit audit events implicitly without +requiring callers to configure auditing directly. For standard catalog events (``DataAccess``, ``ConfigurationChange``, ``UserLoginSuccess``, etc.), construct the protobuf directly and call diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index b03c4e2b..543d83d2 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -15,15 +15,6 @@ AgentGatewaySDKError, ) -from sap_cloud_sdk.agentgateway._implicit_auditlog import ( - send_audit_event_invoked, - send_audit_event_completed, - send_audit_event_failed, - MCP_TOOL_INVOKED, - MCP_TOOL_COMPLETED, - MCP_TOOL_FAILED, -) -from sap_cloud_sdk.agentgateway.config import AuditLogMode, ClientConfig from sap_cloud_sdk.core.telemetry import Module _TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" @@ -1083,143 +1074,6 @@ def test_lob_raises_on_exception(self, _mock_detect): create_client(tenant_subdomain="my-tenant").get_ias_client_id() -# ============================================================ -# Test: send_audit_event_invoked / completed / failed -# ============================================================ - - -class TestSendAuditEventInvoked: - """Tests for send_audit_event_invoked helper.""" - - def test_no_op_without_audit_client(self): - """send_audit_event_invoked is a no-op when audit_client is None.""" - send_audit_event_invoked(None, "tool-name", "user@example.com") - - def test_no_op_without_tenant_id(self): - """send_audit_event_invoked is a no-op when get_tenant_id returns empty string.""" - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value="", - ): - send_audit_event_invoked(mock_audit, "tool-name") - mock_audit.send.assert_not_called() - - def test_sends_invoked_event(self): - """send_audit_event_invoked builds and sends a ZzzCustomEvent with MCP_TOOL_INVOKED.""" - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_invoked(mock_audit, "my-tool", "user@example.com") - mock_audit.send.assert_called_once() - event = mock_audit.send.call_args[0][0] - assert event.common.tenant_id == _TENANT_UUID - assert event.common.user_initiator_id == "user@example.com" - assert event.common.app_context["event_name"] == MCP_TOOL_INVOKED - - def test_sends_without_user_id(self): - """send_audit_event_invoked omits user_initiator_id when user_id is None.""" - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_invoked(mock_audit, "my-tool") - event = mock_audit.send.call_args[0][0] - assert event.common.user_initiator_id == "" - - def test_best_effort_suppresses_send_errors(self): - """send_audit_event_invoked does not propagate exceptions in BEST_EFFORT mode.""" - mock_audit = MagicMock() - mock_audit.send.side_effect = RuntimeError("send failed") - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.BEST_EFFORT) - - def test_strict_raises_on_send_error(self): - """send_audit_event_invoked raises in STRICT mode when send fails.""" - mock_audit = MagicMock() - mock_audit.send.side_effect = RuntimeError("send failed") - with ( - patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ), - pytest.raises(RuntimeError, match="send failed"), - ): - send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.STRICT) - - def test_disabled_skips_send(self): - """send_audit_event_invoked does nothing in DISABLED mode.""" - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_invoked(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) - mock_audit.send.assert_not_called() - - -class TestSendAuditEventCompleted: - """Tests for send_audit_event_completed helper.""" - - def test_sends_completed_event(self): - """send_audit_event_completed sends a ZzzCustomEvent with MCP_TOOL_COMPLETED.""" - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_completed(mock_audit, "my-tool", "user@example.com") - event = mock_audit.send.call_args[0][0] - assert event.common.app_context["event_name"] == MCP_TOOL_COMPLETED - assert event.common.tenant_id == _TENANT_UUID - - def test_no_op_without_audit_client(self): - send_audit_event_completed(None, "tool-name") - - def test_disabled_skips_send(self): - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_completed(mock_audit, "my-tool", mode=AuditLogMode.DISABLED) - mock_audit.send.assert_not_called() - - -class TestSendAuditEventFailed: - """Tests for send_audit_event_failed helper.""" - - def test_sends_failed_event_with_error_type(self): - """send_audit_event_failed sends MCP_TOOL_FAILED with error_type in payload.""" - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_failed(mock_audit, "my-tool", "ValueError", "user@example.com") - event = mock_audit.send.call_args[0][0] - assert event.common.app_context["event_name"] == MCP_TOOL_FAILED - assert event.common.tenant_id == _TENANT_UUID - - def test_no_op_without_audit_client(self): - send_audit_event_failed(None, "tool-name", "RuntimeError") - - def test_disabled_skips_send(self): - mock_audit = MagicMock() - with patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", - return_value=_TENANT_UUID, - ): - send_audit_event_failed(mock_audit, "my-tool", "RuntimeError", mode=AuditLogMode.DISABLED) - mock_audit.send.assert_not_called() - - # ============================================================ # Test: call_mcp_tool audit logging @@ -1235,11 +1089,11 @@ async def test_lob_flow_sends_invoked_and_completed(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1278,11 +1132,11 @@ async def test_customer_flow_no_audit_event(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1320,11 +1174,11 @@ async def test_lob_flow_sends_invoked_and_failed_on_error(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( @@ -1361,11 +1215,11 @@ async def test_audit_event_uses_tool_name(self, mock_tool): mock_audit = MagicMock() with ( patch( - "sap_cloud_sdk.core.auditlog_ng.helper.auditlog_ng.create_client", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", return_value=mock_audit, ), patch( - "sap_cloud_sdk.core.auditlog_ng.helper.get_tenant_id", + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.get_tenant_id", return_value=_TENANT_UUID, ), patch( diff --git a/tests/core/unit/auditlog_ng/unit/test_custom_event.py b/tests/core/unit/auditlog_ng/unit/test_custom_event.py index e3f98abe..ddc875c8 100644 --- a/tests/core/unit/auditlog_ng/unit/test_custom_event.py +++ b/tests/core/unit/auditlog_ng/unit/test_custom_event.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from sap_cloud_sdk.core.auditlog_ng.helper import _emit_custom_event as send_custom_event +from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import _emit_custom_event as send_custom_event from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( auditevent_pb2 as pb, ) From 89d113e1a6d77838b299bbfe414064d17878e13c Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 17 Jul 2026 12:27:08 -0300 Subject: [PATCH 21/24] resolve user initiator ID from JWT token claim in audit events --- src/sap_cloud_sdk/agentgateway/agw_client.py | 36 ++- .../core/auditlog_ng/cross_module_helper.py | 25 +- tests/agentgateway/unit/test_agw_client.py | 287 +++++++++++------- .../auditlog_ng/unit/test_custom_event.py | 21 +- 4 files changed, 236 insertions(+), 133 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 4ade6886..a8b2ed13 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -499,7 +499,6 @@ async def call_mcp_tool( tool: MCPTool, user_token: str | Callable[[], str] | None = None, app_tid: str | None = None, - user_id: str | None = None, **kwargs, ) -> str: """Invoke an MCP tool. @@ -524,8 +523,6 @@ async def call_mcp_tool( for tenant-scoped token exchange. TODO: This parameter's requirement is still being clarified with the IBD team and may be removed if unnecessary. - user_id: User identifier recorded in the audit event when an - audit_client is configured on the client. **kwargs: Tool input parameters (passed directly to the tool). Returns: @@ -568,7 +565,11 @@ async def call_mcp_tool( auth = await self.get_system_auth(app_tid) _send_audit_event( - self._audit_client, McpToolEvent.INVOKED, {"tool": tool.name}, user_id, self._config.audit_log_mode + self._audit_client, + McpToolEvent.INVOKED, + {"tool": tool.name}, + user_token, + self._config.audit_log_mode, ) try: result = await call_mcp_tool_customer( @@ -579,12 +580,16 @@ async def call_mcp_tool( self._audit_client, McpToolEvent.FAILED, {"tool": tool.name, "error_type": type(e).__name__}, - user_id, + user_token, self._config.audit_log_mode, ) raise _send_audit_event( - self._audit_client, McpToolEvent.COMPLETED, {"tool": tool.name}, user_id, self._config.audit_log_mode + self._audit_client, + McpToolEvent.COMPLETED, + {"tool": tool.name}, + user_token, + self._config.audit_log_mode, ) return result @@ -593,17 +598,30 @@ async def call_mcp_tool( logger.warning("app_tid parameter ignored for LoB agent flow") auth = await self.get_user_auth(user_token, app_tid) - _send_audit_event(self._audit_client, McpToolEvent.INVOKED, {"tool": tool.name}, user_id) + _send_audit_event( + self._audit_client, + McpToolEvent.INVOKED, + {"tool": tool.name}, + user_token, + ) try: result = await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) except Exception as e: _send_audit_event( - self._audit_client, McpToolEvent.FAILED, {"tool": tool.name, "error_type": type(e).__name__}, user_id + self._audit_client, + McpToolEvent.FAILED, + {"tool": tool.name, "error_type": type(e).__name__}, + user_token, ) raise - _send_audit_event(self._audit_client, McpToolEvent.COMPLETED, {"tool": tool.name}, user_id) + _send_audit_event( + self._audit_client, + McpToolEvent.COMPLETED, + {"tool": tool.name}, + user_token, + ) return result except AgentGatewaySDKError: diff --git a/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py index ed5398f1..a659cd25 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py @@ -20,6 +20,7 @@ auditevent_pb2 as pb, ) from sap_cloud_sdk.core.telemetry import Module, get_tenant_id +from sap_cloud_sdk.ias import parse_token logger = logging.getLogger(__name__) @@ -44,7 +45,7 @@ def _emit_custom_event( tenant_id: str, event_name: str, payload: dict, - user_id: str | None = None, + user_token: str | Callable[[], str] | None = None, ) -> None: """Build and send a ZzzCustomEvent to the audit log service. @@ -54,12 +55,14 @@ def _emit_custom_event( event_name: Event identifier (e.g. ``"MCP_TOOL_INVOKED"``). payload: Arbitrary key/value pairs serialized into the custom struct. ``event_name`` is always included automatically. - user_id: Optional user initiator ID stamped on the event. + user_token: Optional user JWT. The user initiator ID is resolved from + ``scim_id`` or ``sub`` claims via ``_resolve_user_id``. """ common = pb.Common() common.timestamp.FromDatetime(datetime.now(timezone.utc)) common.tenant_id = tenant_id common.app_context["event_name"] = event_name + user_id = _resolve_user_id(user_token) if user_id: common.user_initiator_id = user_id @@ -77,6 +80,17 @@ def _resolve_tenant(tenant_subdomain: str | Callable[[], str] | None) -> str | N return None +def _resolve_user_id(user_token: str | Callable[[], str] | None) -> str | None: + token = user_token() if callable(user_token) else user_token + if not token: + return None + try: + claims = parse_token(token) + return claims.scim_id or claims.sub or None + except Exception: + return None + + def create_audit_client( tenant_subdomain: str | Callable[[], str] | None, module: Module, @@ -118,7 +132,7 @@ def send_event( audit_client: AuditClient | None, event_name: str, payload: dict, - user_id: str | None = None, + user_token: str | Callable[[], str] | None = None, mode: AuditLogMode = AuditLogMode.BEST_EFFORT, ) -> None: """Send a ZzzCustomEvent to the audit log service. @@ -131,7 +145,8 @@ def send_event( audit_client: Initialized AuditClient, or None to skip. event_name: Event identifier stamped on the event (e.g. ``"MCP_TOOL_INVOKED"``). payload: Arbitrary key/value pairs included in the custom struct. - user_id: Optional user initiator ID. + user_token: Optional user JWT. The user initiator ID is resolved from + ``scim_id`` or ``sub`` claims. mode: Controls failure handling. """ if mode is AuditLogMode.DISABLED: @@ -140,7 +155,7 @@ def send_event( if audit_client is None or not tenant_id: return try: - _emit_custom_event(audit_client, tenant_id, event_name, payload, user_id) + _emit_custom_event(audit_client, tenant_id, event_name, payload, user_token) except Exception: if mode is AuditLogMode.STRICT: raise diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 543d83d2..7cb1b323 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -125,14 +125,17 @@ class TestGetSystemAuth: @pytest.mark.asyncio async def test_lob_flow_returns_auth_result(self): """Return AuthResult from LoB flow.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", - new_callable=AsyncMock, - return_value=("raw-system-jwt-token", "https://agw.example.com"), - ) as mock_auth: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("raw-system-jwt-token", "https://agw.example.com"), + ) as mock_auth, + ): agw_client = create_client(tenant_subdomain="my-tenant") token_cache = _client_token_cache(agw_client) gateway_url_cache = _client_gateway_url_cache(agw_client) @@ -151,15 +154,19 @@ async def test_lob_flow_returns_auth_result(self): @pytest.mark.asyncio async def test_customer_flow_returns_auth_result(self): """Return AuthResult from customer flow.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value="/path/to/credentials", - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", - ) as mock_load, patch( - "sap_cloud_sdk.agentgateway.agw_client.get_system_token_mtls", - return_value="customer-system-token", - ) as mock_mtls: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/path/to/credentials", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", + ) as mock_load, + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_system_token_mtls", + return_value="customer-system-token", + ) as mock_mtls, + ): mock_creds = MagicMock() mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds @@ -173,9 +180,7 @@ async def test_customer_flow_returns_auth_result(self): assert result.access_token == "customer-system-token" assert result.gateway_url == "https://agw.customer.com" mock_load.assert_called_once_with("/path/to/credentials") - mock_mtls.assert_called_once_with( - mock_creds, 60.0, "test-tid", token_cache - ) + mock_mtls.assert_called_once_with(mock_creds, 60.0, "test-tid", token_cache) @pytest.mark.asyncio async def test_missing_tenant_raises_for_lob(self): @@ -186,20 +191,25 @@ async def test_missing_tenant_raises_for_lob(self): ): agw_client = create_client() - with pytest.raises(AgentGatewaySDKError, match="tenant_subdomain is required"): + with pytest.raises( + AgentGatewaySDKError, match="tenant_subdomain is required" + ): await agw_client.get_system_auth() @pytest.mark.asyncio async def test_callable_tenant_subdomain(self): """Accept callable for tenant_subdomain in LoB flow.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", - new_callable=AsyncMock, - return_value=("token", "https://agw.example.com"), - ) as mock_auth: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + return_value=("token", "https://agw.example.com"), + ) as mock_auth, + ): get_tenant = lambda: "dynamic-tenant" agw_client = create_client(tenant_subdomain=get_tenant) token_cache = _client_token_cache(agw_client) @@ -216,17 +226,22 @@ async def test_callable_tenant_subdomain(self): @pytest.mark.asyncio async def test_wraps_unexpected_errors(self): """Wrap unexpected errors in AgentGatewaySDKError.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", - new_callable=AsyncMock, - side_effect=RuntimeError("unexpected"), + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_system_auth", + new_callable=AsyncMock, + side_effect=RuntimeError("unexpected"), + ), ): agw_client = create_client(tenant_subdomain="my-tenant") - with pytest.raises(AgentGatewaySDKError, match="System auth acquisition failed"): + with pytest.raises( + AgentGatewaySDKError, match="System auth acquisition failed" + ): await agw_client.get_system_auth() @@ -241,14 +256,17 @@ class TestGetUserAuth: @pytest.mark.asyncio async def test_lob_flow_returns_auth_result(self): """Return AuthResult from LoB flow.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", - new_callable=AsyncMock, - return_value=("raw-user-jwt-token", "https://agw.example.com"), - ) as mock_auth: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("raw-user-jwt-token", "https://agw.example.com"), + ) as mock_auth, + ): agw_client = create_client(tenant_subdomain="my-tenant") token_cache = _client_token_cache(agw_client) gateway_url_cache = _client_gateway_url_cache(agw_client) @@ -268,15 +286,19 @@ async def test_lob_flow_returns_auth_result(self): @pytest.mark.asyncio async def test_customer_flow_exchanges_token(self): """Exchange token via customer flow and return AuthResult.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value="/path/to/credentials", - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", - ) as mock_load, patch( - "sap_cloud_sdk.agentgateway.agw_client.exchange_user_token", - return_value="exchanged-token", - ) as mock_exchange: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/path/to/credentials", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", + ) as mock_load, + patch( + "sap_cloud_sdk.agentgateway.agw_client.exchange_user_token", + return_value="exchanged-token", + ) as mock_exchange, + ): mock_creds = MagicMock() mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds @@ -310,14 +332,17 @@ async def test_missing_user_token_raises(self): @pytest.mark.asyncio async def test_callable_user_token(self): """Accept callable for user_token.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", - new_callable=AsyncMock, - return_value=("token", "https://agw.example.com"), - ) as mock_auth: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + return_value=("token", "https://agw.example.com"), + ) as mock_auth, + ): agw_client = create_client(tenant_subdomain="my-tenant") get_token = lambda: "dynamic-user-jwt" token_cache = _client_token_cache(agw_client) @@ -341,19 +366,24 @@ async def test_missing_tenant_raises_for_lob(self): ): agw_client = create_client() - with pytest.raises(AgentGatewaySDKError, match="tenant_subdomain is required"): + with pytest.raises( + AgentGatewaySDKError, match="tenant_subdomain is required" + ): await agw_client.get_user_auth(user_token="user-jwt") @pytest.mark.asyncio async def test_wraps_unexpected_errors(self): """Wrap unexpected errors in AgentGatewaySDKError.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value=None, - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", - new_callable=AsyncMock, - side_effect=RuntimeError("unexpected"), + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value=None, + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.fetch_user_auth", + new_callable=AsyncMock, + side_effect=RuntimeError("unexpected"), + ), ): agw_client = create_client(tenant_subdomain="my-tenant") @@ -503,19 +533,24 @@ async def test_returns_tools_from_lob_flow(self): @pytest.mark.asyncio async def test_customer_flow_passes_system_token(self): """Customer flow passes pre-fetched system token to get_mcp_tools_customer.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value="/path/to/credentials", - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", - ) as mock_load, patch( - "sap_cloud_sdk.agentgateway.agw_client.get_system_token_mtls", - return_value="customer-system-token", - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", - new_callable=AsyncMock, - return_value=[], - ) as mock_customer: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/path/to/credentials", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", + ) as mock_load, + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_system_token_mtls", + return_value="customer-system-token", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", + new_callable=AsyncMock, + return_value=[], + ) as mock_customer, + ): mock_creds = MagicMock() mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds @@ -557,19 +592,24 @@ async def test_lob_flow_with_user_token_uses_user_auth(self): @pytest.mark.asyncio async def test_customer_flow_with_user_token_uses_user_auth(self): """Customer flow uses user auth when user_token is provided.""" - with patch( - "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", - return_value="/path/to/credentials", - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", - ) as mock_load, patch( - "sap_cloud_sdk.agentgateway.agw_client.exchange_user_token", - return_value="exchanged-user-token", - ), patch( - "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", - new_callable=AsyncMock, - return_value=[], - ) as mock_customer: + with ( + patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials", + return_value="/path/to/credentials", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials", + ) as mock_load, + patch( + "sap_cloud_sdk.agentgateway.agw_client.exchange_user_token", + return_value="exchanged-user-token", + ), + patch( + "sap_cloud_sdk.agentgateway.agw_client.get_mcp_tools_customer", + new_callable=AsyncMock, + return_value=[], + ) as mock_customer, + ): mock_creds = MagicMock() mock_creds.gateway_url = "https://agw.customer.com" mock_load.return_value = mock_creds @@ -741,9 +781,7 @@ async def test_customer_credentials_calls_customer_flow(self, mock_tool): assert result == "customer result" # load_customer_credentials is called once in get_user_auth() mock_load.assert_called_once_with("/path/to/credentials") - mock_customer.assert_called_once_with( - mock_tool, "exchanged-token", 60.0 - ) + mock_customer.assert_called_once_with(mock_tool, "exchanged-token", 60.0) @pytest.mark.asyncio async def test_customer_flow_falls_back_to_system_token(self, mock_tool): @@ -778,9 +816,7 @@ async def test_customer_flow_falls_back_to_system_token(self, mock_tool): ) assert result == "result with system token" - mock_customer.assert_called_once_with( - mock_tool, "system-token", 60.0 - ) + mock_customer.assert_called_once_with(mock_tool, "system-token", 60.0) @pytest.mark.asyncio async def test_calls_lob_flow_with_exchanged_token(self, mock_tool): @@ -906,7 +942,9 @@ async def test_passes_filter_arguments(self): ): agw_client = create_client(tenant_subdomain="my-tenant") await agw_client.list_agent_cards( - filter=AgentCardFilter(agent_names=["Billing Agent"], ord_ids=["sap.s4:agent:v1"]) + filter=AgentCardFilter( + agent_names=["Billing Agent"], ord_ids=["sap.s4:agent:v1"] + ) ) mock_lob.assert_called_once_with( @@ -925,7 +963,9 @@ async def test_raises_for_customer_agent_flow(self): return_value="/path/to/credentials", ): agw_client = create_client() - with pytest.raises(AgentGatewaySDKError, match="not yet supported for customer agents"): + with pytest.raises( + AgentGatewaySDKError, match="not yet supported for customer agents" + ): await agw_client.list_agent_cards() @pytest.mark.asyncio @@ -982,7 +1022,9 @@ async def test_wraps_unexpected_error(self): ), ): agw_client = create_client(tenant_subdomain="my-tenant") - with pytest.raises(AgentGatewaySDKError, match="Agent card discovery failed"): + with pytest.raises( + AgentGatewaySDKError, match="Agent card discovery failed" + ): await agw_client.list_agent_cards() @@ -1013,8 +1055,12 @@ def test_explicit_source_is_stored(self): _DEST_CREATE_PATCH = "sap_cloud_sdk.destination.create_client" _IAS_DEST_NAME_PATCH = "sap_cloud_sdk.agentgateway._lob._ias_dest_name" -_GET_IAS_CLIENT_ID_LOB_PATCH = "sap_cloud_sdk.agentgateway.agw_client.get_ias_client_id_lob" -_DETECT_CREDS_PATCH = "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials" +_GET_IAS_CLIENT_ID_LOB_PATCH = ( + "sap_cloud_sdk.agentgateway.agw_client.get_ias_client_id_lob" +) +_DETECT_CREDS_PATCH = ( + "sap_cloud_sdk.agentgateway.agw_client.detect_customer_agent_credentials" +) _LOAD_CREDS_PATCH = "sap_cloud_sdk.agentgateway.agw_client.load_customer_credentials" _NO_CUSTOMER_CREDS = patch(_DETECT_CREDS_PATCH, return_value=None) @@ -1042,7 +1088,9 @@ def test_customer_raises_on_load_failure(self): patch(_DETECT_CREDS_PATCH, return_value="/etc/ums/credentials/credentials"), patch(_LOAD_CREDS_PATCH, side_effect=Exception("parse error")), ): - with pytest.raises(AgentGatewaySDKError, match="Could not resolve IAS client ID"): + with pytest.raises( + AgentGatewaySDKError, match="Could not resolve IAS client ID" + ): create_client().get_ias_client_id() # --- LoB flow --- @@ -1056,7 +1104,12 @@ def test_lob_returns_client_id_from_destination_properties(self, _mock_detect): @_NO_CUSTOMER_CREDS def test_lob_raises_when_destination_not_found(self, _mock_detect): - with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, side_effect=AgentGatewaySDKError("IAS destination 'sap-managed-runtime-ias-eu10' not found")): + with patch( + _GET_IAS_CLIENT_ID_LOB_PATCH, + side_effect=AgentGatewaySDKError( + "IAS destination 'sap-managed-runtime-ias-eu10' not found" + ), + ): with pytest.raises(AgentGatewaySDKError, match="IAS destination"): create_client(tenant_subdomain="my-tenant").get_ias_client_id() @@ -1069,12 +1122,16 @@ def test_lob_returns_empty_string_when_property_absent(self, _mock_detect): @_NO_CUSTOMER_CREDS def test_lob_raises_on_exception(self, _mock_detect): - with patch(_GET_IAS_CLIENT_ID_LOB_PATCH, side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")): - with pytest.raises(AgentGatewaySDKError, match="Could not resolve IAS client ID"): + with patch( + _GET_IAS_CLIENT_ID_LOB_PATCH, + side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set"), + ): + with pytest.raises( + AgentGatewaySDKError, match="Could not resolve IAS client ID" + ): create_client(tenant_subdomain="my-tenant").get_ias_client_id() - # ============================================================ # Test: call_mcp_tool audit logging # ============================================================ @@ -1115,7 +1172,6 @@ async def test_lob_flow_sends_invoked_and_completed(self, mock_tool): await agw_client.call_mcp_tool( tool=mock_tool, user_token="user-jwt", - user_id="user@example.com", ) assert mock_audit.send.call_count == 2 @@ -1123,7 +1179,6 @@ async def test_lob_flow_sends_invoked_and_completed(self, mock_tool): completed_event = mock_audit.send.call_args_list[1][0][0] assert invoked_event.common.app_context["event_name"] == "MCP_TOOL_INVOKED" assert invoked_event.common.tenant_id == _TENANT_UUID - assert invoked_event.common.user_initiator_id == "user@example.com" assert completed_event.common.app_context["event_name"] == "MCP_TOOL_COMPLETED" @pytest.mark.asyncio @@ -1241,4 +1296,6 @@ async def test_audit_event_uses_tool_name(self, mock_tool): await agw_client.call_mcp_tool(tool=mock_tool, user_token="jwt") invoked_event = mock_audit.send.call_args_list[0][0][0] - assert invoked_event.custom.struct_value.fields["tool"].string_value == "test-tool" + assert ( + invoked_event.custom.struct_value.fields["tool"].string_value == "test-tool" + ) diff --git a/tests/core/unit/auditlog_ng/unit/test_custom_event.py b/tests/core/unit/auditlog_ng/unit/test_custom_event.py index ddc875c8..f93f13e3 100644 --- a/tests/core/unit/auditlog_ng/unit/test_custom_event.py +++ b/tests/core/unit/auditlog_ng/unit/test_custom_event.py @@ -2,7 +2,9 @@ from unittest.mock import MagicMock -from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import _emit_custom_event as send_custom_event +from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import ( + _emit_custom_event as send_custom_event, +) from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import ( auditevent_pb2 as pb, ) @@ -31,14 +33,25 @@ def test_payload_includes_event_name_and_custom_keys(self): assert fields["tool"].string_value == "my-tool" def test_sets_user_initiator_id_when_provided(self): - """send_custom_event stamps user_id on common.user_initiator_id.""" + """send_custom_event stamps user_initiator_id resolved from token scim_id.""" + from unittest.mock import patch, MagicMock as MM + mock_client = MagicMock() - send_custom_event(mock_client, _TENANT_UUID, "MY_EVENT", {}, user_id="user@example.com") + mock_claims = MM() + mock_claims.scim_id = "user@example.com" + mock_claims.sub = None + with patch( + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.parse_token", + return_value=mock_claims, + ): + send_custom_event( + mock_client, _TENANT_UUID, "MY_EVENT", {}, user_token="fake.jwt.token" + ) event = mock_client.send.call_args[0][0] assert event.common.user_initiator_id == "user@example.com" def test_omits_user_initiator_id_when_none(self): - """send_custom_event leaves user_initiator_id empty when user_id is None.""" + """send_custom_event leaves user_initiator_id empty when user_token is None.""" mock_client = MagicMock() send_custom_event(mock_client, _TENANT_UUID, "MY_EVENT", {}) event = mock_client.send.call_args[0][0] From 112e7d8c22b0f970b83042da2be63e4d20678d9e Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 17 Jul 2026 12:43:13 -0300 Subject: [PATCH 22/24] separate token resolving logic from getting user id from token --- .../core/auditlog_ng/cross_module_helper.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py index a659cd25..ade7b738 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py @@ -62,7 +62,7 @@ def _emit_custom_event( common.timestamp.FromDatetime(datetime.now(timezone.utc)) common.tenant_id = tenant_id common.app_context["event_name"] = event_name - user_id = _resolve_user_id(user_token) + user_id = _get_user_id(user_token) if user_id: common.user_initiator_id = user_id @@ -80,8 +80,16 @@ def _resolve_tenant(tenant_subdomain: str | Callable[[], str] | None) -> str | N return None -def _resolve_user_id(user_token: str | Callable[[], str] | None) -> str | None: - token = user_token() if callable(user_token) else user_token +def _resolve_user_token(user_token: str | Callable[[], str] | None) -> str | None: + if isinstance(user_token, str): + return user_token + if callable(user_token): + return user_token() + return None + + +def _get_user_id(user_token: str | Callable[[], str] | None) -> str | None: + token = _resolve_user_token(user_token) if not token: return None try: From a6226307c5203c1f99738b4c685e4e6e5f031eb8 Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 17 Jul 2026 13:00:01 -0300 Subject: [PATCH 23/24] rollback minor change in list_mcp_tool --- src/sap_cloud_sdk/agentgateway/agw_client.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index a8b2ed13..8a9aaf18 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -400,10 +400,9 @@ async def list_mcp_tools( auth = await self.get_user_auth(user_token, app_tid) else: auth = await self.get_system_auth(app_tid=app_tid) - tools = await get_mcp_tools_customer( + return await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout - ) - return tools + ) # LoB flow - requires tenant_subdomain if app_tid: @@ -414,10 +413,9 @@ async def list_mcp_tools( auth = await self.get_user_auth(user_token) else: auth = await self.get_system_auth() - tools = await get_mcp_tools_lob( + return await get_mcp_tools_lob( tenant, auth.access_token, self._config.timeout ) - return tools except AgentGatewaySDKError: # Re-raise SDK errors as-is From 3b5db8e9edaa516d9d613dbd09760d5bd92f017d Mon Sep 17 00:00:00 2001 From: Soares Date: Fri, 17 Jul 2026 13:01:16 -0300 Subject: [PATCH 24/24] ruff format --- src/sap_cloud_sdk/agentgateway/agw_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 8a9aaf18..3680a6fa 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -402,7 +402,7 @@ async def list_mcp_tools( auth = await self.get_system_auth(app_tid=app_tid) return await get_mcp_tools_customer( credentials, auth.access_token, self._config.timeout - ) + ) # LoB flow - requires tenant_subdomain if app_tid: