diff --git a/pyproject.toml b/pyproject.toml index 8cd30f63..e83b4abf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.33.1" +version = "0.34.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" 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..462f9fb2 --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_auditlog_helper.py @@ -0,0 +1,67 @@ +"""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__) + + +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 + try: + return auditlog_ng.create_client( + tenant=resolved, + _telemetry_source=module, + ) + except Exception: + if mode is AuditLogMode.STRICT: + raise + logger.warning("Failed to create audit client — audit events will not be recorded", exc_info=True) + return None + + +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.""" + 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 + 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 33a0ccfc..a6480355 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -34,9 +34,15 @@ 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 -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.core.auditlog_ng import AuditClient +from sap_cloud_sdk.core.telemetry import ( + Module, + Operation, + record_metrics, +) logger = logging.getLogger(__name__) @@ -120,6 +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 = create_audit_client( + tenant_subdomain, Module.AGENTGATEWAY, self._config.audit_log_mode + ) @staticmethod def _resolve_value( @@ -384,9 +393,10 @@ 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 ) + return tools # LoB flow - requires tenant_subdomain if app_tid: @@ -397,9 +407,10 @@ 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 ) + return tools except AgentGatewaySDKError: # Re-raise SDK errors as-is @@ -481,6 +492,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. @@ -505,6 +517,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: @@ -546,18 +560,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 ) + 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 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 ) + send_audit_event(self._audit_client, tool.name, user_id) + return result except AgentGatewaySDKError: # Re-raise SDK errors as-is @@ -644,4 +662,7 @@ 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, + ) 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 8df9e96a..9086ef46 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -14,6 +14,10 @@ MCPTool, 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" # ============================================================ @@ -986,6 +990,7 @@ async def test_wraps_unexpected_error(self): # Test: get_ias_client_id # ============================================================ + _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" @@ -1047,3 +1052,253 @@ 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"): create_client(tenant_subdomain="my-tenant").get_ias_client_id() + + +# ============================================================ +# 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 None.""" + # Should not raise + 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.""" + mock_audit = MagicMock() + with patch( + "sap_cloud_sdk.agentgateway._auditlog_helper.get_tenant_id", + return_value="", + ): + 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.""" + 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") + 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() + 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() + 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.""" + 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) + + 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() + + + +# ============================================================ +# 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._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_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") + 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_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._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.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() + 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_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._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_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") + 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._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_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") + 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"