Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
67 changes: 67 additions & 0 deletions src/sap_cloud_sdk/agentgateway/_auditlog_helper.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 27 additions & 6 deletions src/sap_cloud_sdk/agentgateway/agw_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed implicit auditlog from list_mcp_tool() and kept it only on call_mcp_tool()

audit_client is configured on the client.
**kwargs: Tool input parameters (passed directly to the tool).

Returns:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
19 changes: 19 additions & 0 deletions src/sap_cloud_sdk/agentgateway/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -22,13 +38,16 @@ 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
fallback_token_ttl_seconds: float = DEFAULT_FALLBACK_TOKEN_TTL_SECONDS
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:
Expand Down
Loading