diff --git a/pyproject.toml b/pyproject.toml index a9e4a46a..9a8cf7a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.36.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" 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/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 5eb0ec16..3680a6fa 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -34,9 +34,19 @@ AuthResult, MCPTool, ) +from sap_cloud_sdk.core.auditlog_ng import AuditClient +from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import ( + create_audit_client, + 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.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.core.telemetry import ( + Module, + Operation, + record_metrics, +) logger = logging.getLogger(__name__) @@ -123,6 +133,9 @@ def __init__( self._token_cache = _TokenCache(self._config) self._gateway_url_cache = _GatewayUrlCache() self._telemetry_source = _telemetry_source + self._audit_client: AuditClient | None = create_audit_client( + tenant_subdomain, Module.AGENTGATEWAY, self._config.audit_log_mode + ) @staticmethod def _resolve_value( @@ -549,18 +562,65 @@ async def call_mcp_tool( ) auth = await self.get_system_auth(app_tid) - return await call_mcp_tool_customer( - tool, auth.access_token, self._config.timeout, **kwargs + _send_audit_event( + self._audit_client, + McpToolEvent.INVOKED, + {"tool": tool.name}, + user_token, + 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( + self._audit_client, + McpToolEvent.FAILED, + {"tool": tool.name, "error_type": type(e).__name__}, + user_token, + self._config.audit_log_mode, + ) + raise + _send_audit_event( + self._audit_client, + McpToolEvent.COMPLETED, + {"tool": tool.name}, + user_token, + 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( - tool, auth.access_token, self._config.timeout, **kwargs + _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_token, + ) + raise + _send_audit_event( + self._audit_client, + McpToolEvent.COMPLETED, + {"tool": tool.name}, + user_token, ) + return result except AgentGatewaySDKError: # Re-raise SDK errors as-is diff --git a/src/sap_cloud_sdk/agentgateway/config.py b/src/sap_cloud_sdk/agentgateway/config.py index 17495dbd..959424c4 100644 --- a/src/sap_cloud_sdk/agentgateway/config.py +++ b/src/sap_cloud_sdk/agentgateway/config.py @@ -2,6 +2,10 @@ from dataclasses import dataclass +from sap_cloud_sdk.core.auditlog_ng.cross_module_helper import AuditLogMode + +__all__ = ["AuditLogMode", "ClientConfig"] + DEFAULT_TIMEOUT_SECONDS = 60.0 DEFAULT_FALLBACK_TOKEN_TTL_SECONDS = 300.0 DEFAULT_TOKEN_EXPIRY_BUFFER_SECONDS = 30.0 @@ -22,6 +26,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 +35,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/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/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index c262b608..278d3977 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.cross_module_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/cross_module_helper.py b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py new file mode 100644 index 00000000..ade7b738 --- /dev/null +++ b/src/sap_cloud_sdk/core/auditlog_ng/cross_module_helper.py @@ -0,0 +1,171 @@ +"""Implicit audit log helpers shared across SDK modules. + +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 +``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 +from sap_cloud_sdk.ias import parse_token + +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_token: str | Callable[[], 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_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 = _get_user_id(user_token) + 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 _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: + 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, + 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_token: str | Callable[[], 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_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: + 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_token) + 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/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 diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 3e777d85..7cb1b323 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -14,8 +14,11 @@ MCPTool, AgentGatewaySDKError, ) + from sap_cloud_sdk.core.telemetry import Module +_TENANT_UUID = "9e0d89c9-17cd-439d-8a8b-9c44d3d272f0" + # ============================================================ # Fixtures @@ -122,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) @@ -148,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 @@ -170,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): @@ -183,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) @@ -213,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() @@ -238,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) @@ -265,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 @@ -307,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) @@ -338,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") @@ -500,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 @@ -554,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 @@ -738,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): @@ -775,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): @@ -903,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( @@ -922,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 @@ -979,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() @@ -1007,10 +1052,15 @@ def test_explicit_source_is_stored(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" -_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) @@ -1038,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 --- @@ -1052,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() @@ -1065,6 +1122,180 @@ 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 +# ============================================================ + + +class TestCallMcpToolAuditLog: + """Tests that call_mcp_tool emits audit events for the full tool lifecycle.""" + + @pytest.mark.asyncio + 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( + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.core.auditlog_ng.cross_module_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", + ) + + 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 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 events for customer agents (no tenant_subdomain).""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.core.auditlog_ng.cross_module_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_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( + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.core.auditlog_ng.cross_module_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", + ) + + 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(self, mock_tool): + """call_mcp_tool stamps tool.name in the invoked audit event payload.""" + mock_audit = MagicMock() + with ( + patch( + "sap_cloud_sdk.core.auditlog_ng.cross_module_helper.auditlog_ng.create_client", + return_value=mock_audit, + ), + patch( + "sap_cloud_sdk.core.auditlog_ng.cross_module_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") + + invoked_event = mock_audit.send.call_args_list[0][0][0] + 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 new file mode 100644 index 00000000..f93f13e3 --- /dev/null +++ b/tests/core/unit/auditlog_ng/unit/test_custom_event.py @@ -0,0 +1,68 @@ +"""Unit tests for send_custom_event.""" + +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.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_initiator_id resolved from token scim_id.""" + from unittest.mock import patch, MagicMock as MM + + mock_client = MagicMock() + 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_token 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