diff --git a/.gitignore b/.gitignore index ae33b087..8fae5530 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ RELEASE.md # macOS metadata .DS_Store +test-agent/ diff --git a/pyproject.toml b/pyproject.toml index 3bbf3426..04a0b66a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.33.0" +version = "0.33.18" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/__init__.py b/src/sap_cloud_sdk/agentgateway/__init__.py index e70b7275..69c5f1e8 100644 --- a/src/sap_cloud_sdk/agentgateway/__init__.py +++ b/src/sap_cloud_sdk/agentgateway/__init__.py @@ -63,6 +63,7 @@ from sap_cloud_sdk.agentgateway.agw_client import create_client, AgentGatewayClient from sap_cloud_sdk.agentgateway.exceptions import ( AgentGatewaySDKError, + AgentGatewayServerError, MCPServerNotFoundError, ) @@ -82,5 +83,6 @@ "AgentCardFilter", # Exceptions "AgentGatewaySDKError", + "AgentGatewayServerError", "MCPServerNotFoundError", ] diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index be5019ea..c3b5f289 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -1,11 +1,16 @@ """Customer agent flow - file-based credentials with mTLS authentication. -Customer agents read credentials from a file mounted on the pod filesystem. -This flow is used when credential files are detected. +Customer agents read credentials from a file mounted on the pod filesystem (STANDARD mode) +or from environment variables (TRANSPARENT mode). -Authentication flow: +Authentication flow (STANDARD mode): - Tool discovery: mTLS client credentials → system-scoped token - Tool invocation: mTLS + jwt-bearer grant → user-scoped token (principal propagation) + +Authentication flow (TRANSPARENT mode): +- Tool discovery: client credentials (no mTLS) → system-scoped token +- Tool invocation: client credentials + jwt-bearer grant → user-scoped token +- Gateway handles mTLS externally, SDK uses standard HTTPS """ import json @@ -19,6 +24,10 @@ from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client +from sap_cloud_sdk.agentgateway._dependencies_resolver import ( + EnvironmentDependenciesResolver, + IntegrationDependenciesResolver, +) from sap_cloud_sdk.agentgateway._models import ( CustomerCredentials, IntegrationDependency, @@ -26,14 +35,22 @@ ) from sap_cloud_sdk.agentgateway._token_cache import _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError +from sap_cloud_sdk.core.secret_resolver import resolve_base_mount logger = logging.getLogger(__name__) -# Environment variable to override default credential path -_CREDENTIALS_PATH_ENV = "AGW_CREDENTIALS_PATH" +# servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals the expected type +_BINDING_TYPE = "integration-credentials" +_BINDING_TYPE_FILE = "type" +_CREDENTIALS_FILE = "credentials" -# Default credential path for Kyma production deployments -_CREDENTIALS_DEFAULT_PATH = "/etc/ums/credentials/credentials" +# Kyma default when SERVICE_BINDING_ROOT is not set +_DEFAULT_BINDING_ROOT = "/bindings" + +# Environment variables for transparent mode +_INTEGRATION_CLIENT_ID_ENV = "INTEGRATION_CLIENT_ID" +_INTEGRATION_AUTH_URL_ENV = "INTEGRATION_AUTH_URL" +_INTEGRATION_GATEWAY_URL_ENV = "INTEGRATION_GATEWAY_URL" # Resource URN for Agent Gateway token scope (hardcoded - production value) _AGW_RESOURCE_URN = "urn:sap:identity:application:provider:name:agent-gateway" @@ -43,9 +60,9 @@ _GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer" -def _cache_scope_key(credentials: CustomerCredentials, app_tid: str | None) -> str: +def _cache_scope_key(credentials: CustomerCredentials) -> str: """Build a cache scope key for customer-flow tokens.""" - return f"customer::{credentials.client_id}::{app_tid or ''}" + return f"customer::{credentials.client_id}" class _CredentialFields: @@ -58,36 +75,107 @@ class _CredentialFields: GATEWAY_URL = "gatewayUrl" INTEGRATION_DEPENDENCIES = "integrationDependencies" ORD_ID = "ordId" - DATA = "data" GLOBAL_TENANT_ID = "globalTenantId" def detect_customer_agent_credentials() -> str | None: """Check if customer agent credentials file exists. - Checks for credential file in the following order: - 1. Path specified in AGW_CREDENTIALS_PATH env var - 2. Default mounted path: /etc/ums/credentials/credentials + $SERVICE_BINDING_ROOT (or /bindings if unset): scans all subdirectories for one whose + 'type' file contains 'integration-credentials', then reads 'credentials' from that directory Returns: Path to credentials file if found, None otherwise. """ - # Check env var first (path may be customized) - path_from_env = os.environ.get(_CREDENTIALS_PATH_ENV) - if path_from_env and os.path.isfile(path_from_env): - logger.debug("Customer credentials found at env var path: %s", path_from_env) - return path_from_env - - # Check default mounted path - if os.path.isfile(_CREDENTIALS_DEFAULT_PATH): - logger.debug( - "Customer credentials found at default path: %s", _CREDENTIALS_DEFAULT_PATH - ) - return _CREDENTIALS_DEFAULT_PATH + + # servicebinding.io: scan $SERVICE_BINDING_ROOT for a binding whose 'type' file equals _BINDING_TYPE + sbr = resolve_base_mount(_DEFAULT_BINDING_ROOT) + if sbr and os.path.isdir(sbr): + for entry in os.scandir(sbr): + if not entry.is_dir(): + continue + type_file = os.path.join(entry.path, _BINDING_TYPE_FILE) + if not os.path.isfile(type_file): + continue + with open(type_file) as f: + if f.read().strip() != _BINDING_TYPE: + continue + credentials_path = os.path.join(entry.path, _CREDENTIALS_FILE) + if os.path.isfile(credentials_path): + logger.debug( + "Customer credentials found via servicebinding.io type scan: %s", + credentials_path, + ) + return credentials_path return None +def detect_transparent_credentials() -> bool: + """Check if transparent mode environment variables are present. + + Checks for required environment variables: + - INTEGRATION_CLIENT_ID + - INTEGRATION_AUTH_URL + - INTEGRATION_GATEWAY_URL + + Returns: + True if all required environment variables are present, False otherwise. + """ + has_client_id = bool(os.environ.get(_INTEGRATION_CLIENT_ID_ENV)) + has_auth_url = bool(os.environ.get(_INTEGRATION_AUTH_URL_ENV)) + has_gateway_url = bool(os.environ.get(_INTEGRATION_GATEWAY_URL_ENV)) + return has_client_id and has_auth_url and has_gateway_url + + +def load_customer_credentials_from_env( + dependencies_resolver: IntegrationDependenciesResolver | None = None, +) -> CustomerCredentials: + """Load customer credentials from environment variables (transparent mode). + + Args: + dependencies_resolver: Optional custom resolver for integration dependencies. + If None, uses EnvironmentDependenciesResolver (INTEGRATION_DEPENDENCIES env var). + + Returns: + CustomerCredentials configured for transparent mode. + + Raises: + AgentGatewaySDKError: If required environment variables are missing or invalid. + """ + logger.info("using transparent mode") + + # Check required environment variables + required_vars = { + _INTEGRATION_CLIENT_ID_ENV: "client_id", + _INTEGRATION_AUTH_URL_ENV: "auth_url", + _INTEGRATION_GATEWAY_URL_ENV: "gateway_url", + } + + missing = [var for var in required_vars if not os.environ.get(var)] + if missing: + raise AgentGatewaySDKError( + f"Transparent TLS mode requires environment variables: {', '.join(missing)}" + ) + + client_id = os.environ[_INTEGRATION_CLIENT_ID_ENV] + token_service_url = os.environ[_INTEGRATION_AUTH_URL_ENV] + gateway_url = os.environ[_INTEGRATION_GATEWAY_URL_ENV].rstrip("/") + + # Resolve integration dependencies + resolver = dependencies_resolver or EnvironmentDependenciesResolver() + integration_dependencies = resolver.resolve() + + return CustomerCredentials( + token_service_url=token_service_url, + client_id=client_id, + gateway_url=gateway_url, + integration_dependencies=integration_dependencies, + certificate=None, + private_key=None, + ) + + def load_customer_credentials(path: str) -> CustomerCredentials: """Load and parse customer credentials from file. @@ -128,16 +216,14 @@ def load_customer_credentials(path: str) -> CustomerCredentials: if _CredentialFields.INTEGRATION_DEPENDENCIES not in data: raise AgentGatewaySDKError( "Credentials file missing required field: integrationDependencies. " - 'Expected format: [{"ordId": "...", "data": {"globalTenantId": "..."}}]' + 'Expected format: [{"ordId": "...", "globalTenantId": "..."}]' ) try: integration_deps = [ IntegrationDependency( ord_id=dep[_CredentialFields.ORD_ID], - global_tenant_id=dep[_CredentialFields.DATA][ - _CredentialFields.GLOBAL_TENANT_ID - ], + global_tenant_id=dep[_CredentialFields.GLOBAL_TENANT_ID], ) for dep in data[_CredentialFields.INTEGRATION_DEPENDENCIES] ] @@ -148,22 +234,25 @@ def load_customer_credentials(path: str) -> CustomerCredentials: except (KeyError, TypeError) as e: raise AgentGatewaySDKError( f"Failed to parse integrationDependencies: {e}. " - 'Expected format: [{"ordId": "...", "data": {"globalTenantId": "..."}}]' + 'Expected format: [{"ordId": "...", "globalTenantId": "..."}]' ) return CustomerCredentials( token_service_url=data[_CredentialFields.TOKEN_SERVICE_URL], client_id=data[_CredentialFields.CLIENT_ID], - certificate=data[_CredentialFields.CERTIFICATE], - private_key=data[_CredentialFields.PRIVATE_KEY], gateway_url=data[_CredentialFields.GATEWAY_URL].rstrip("/"), integration_dependencies=integration_deps, + certificate=data[_CredentialFields.CERTIFICATE], + private_key=data[_CredentialFields.PRIVATE_KEY], ) def _create_ssl_context(certificate: str, private_key: str) -> ssl.SSLContext: """Create SSL context for mTLS from in-memory certificate and key. + Only used in STANDARD mode with file-based credentials. + In TRANSPARENT mode, standard HTTPS is used without custom SSL context. + Uses temporary files as a bridge since ssl.SSLContext requires file paths or loaded certificate objects. The files are created with secure permissions and cleaned up immediately after loading. @@ -216,7 +305,6 @@ def _request_token_mtls( credentials: CustomerCredentials, grant_type: str, timeout: float, - app_tid: str | None = None, extra_data: dict | None = None, ) -> dict: """Make mTLS token request to IAS. @@ -225,7 +313,6 @@ def _request_token_mtls( credentials: Customer credentials with certificate and private key. grant_type: OAuth2 grant type. timeout: HTTP timeout in seconds. - app_tid: BTP Application Tenant ID of subscriber (optional). extra_data: Additional form data for the token request. Returns: @@ -242,11 +329,6 @@ def _request_token_mtls( "resource": _AGW_RESOURCE_URN, } - # TODO: app_tid requirement is still being clarified with the IBD team. - # This parameter may be removed if it turns out to be unnecessary. - if app_tid: - data["app_tid"] = app_tid - if extra_data: data.update(extra_data) @@ -295,40 +377,128 @@ def _request_token_mtls( raise AgentGatewaySDKError(f"Token request failed: {e}") +def _request_token_transparent( + credentials: CustomerCredentials, + grant_type: str, + timeout: float, + extra_data: dict | None = None, +) -> dict: + """Make standard HTTPS token request without mTLS (transparent mode). + + Used in transparent mode where the gateway handles mTLS externally. + The SDK makes a standard HTTPS request without client certificates. + + Args: + credentials: Customer credentials (certificate and private_key should be None). + grant_type: OAuth2 grant type. + timeout: HTTP timeout in seconds. + extra_data: Additional form data for the token request. + + Returns: + Token response payload. + + Raises: + AgentGatewaySDKError: If token request fails. + """ + data = { + "client_id": credentials.client_id, + "grant_type": grant_type, + "resource": _AGW_RESOURCE_URN, + } + + if extra_data: + data.update(extra_data) + + logger.debug( + "Requesting token from %s with grant_type=%s (transparent mode)", + credentials.token_service_url, + grant_type, + ) + + try: + with httpx.Client(timeout=timeout) as client: + response = client.post( + credentials.token_service_url, + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + + if response.status_code != 200: + logger.error( + "Token request failed with status %d: %s", + response.status_code, + response.text[:500], + ) + raise AgentGatewaySDKError( + f"Token request failed with status {response.status_code}: {response.text[:200]}" + ) + + token_data = response.json() + access_token = token_data.get("access_token") + + if not access_token: + raise AgentGatewaySDKError( + f"Token response missing 'access_token'. Keys: {list(token_data.keys())}" + ) + + logger.debug("Token acquired successfully (length: %d)", len(access_token)) + return token_data + + except httpx.RequestError as e: + raise AgentGatewaySDKError(f"Token request failed: {e}") + + def get_system_token_mtls( credentials: CustomerCredentials, timeout: float, - app_tid: str | None = None, token_cache: _TokenCache | None = None, ) -> str: - """Get system-scoped token using mTLS client credentials flow. + """Get system-scoped token using client credentials flow. + + Automatically selects authentication mode based on credentials: + - STANDARD mode: Uses mTLS with certificate and private key + - TRANSPARENT mode: Uses standard HTTPS (gateway handles mTLS) Used for tool discovery where user identity is not needed. Args: credentials: Customer credentials. timeout: HTTP timeout in seconds. - app_tid: BTP Application Tenant ID of subscriber (optional). token_cache: Optional token cache used to reuse still-valid tokens. Returns: System-scoped access token, fetched or served from cache. """ - scope_key = _cache_scope_key(credentials, app_tid) + scope_key = _cache_scope_key(credentials) if token_cache: cached_token = token_cache.get_system_token(scope_key) if cached_token: logger.debug("Using cached system token for scope '%s'", scope_key) return cached_token - logger.info("Acquiring system token via mTLS client credentials") - token_data = _request_token_mtls( - credentials, - grant_type=_GRANT_TYPE_CLIENT_CREDENTIALS, - timeout=timeout, - app_tid=app_tid, - extra_data={"response_type": "token"}, - ) + # Determine which token request method to use based on credentials + if credentials.certificate and credentials.private_key: + # STANDARD mode: mTLS authentication + logger.info("Acquiring system token via mTLS client credentials") + token_data = _request_token_mtls( + credentials, + grant_type=_GRANT_TYPE_CLIENT_CREDENTIALS, + timeout=timeout, + extra_data={"response_type": "token"}, + ) + else: + # TRANSPARENT mode: standard HTTPS authentication + logger.info("Acquiring system token via transparent client credentials") + token_data = _request_token_transparent( + credentials, + grant_type=_GRANT_TYPE_CLIENT_CREDENTIALS, + timeout=timeout, + extra_data={"response_type": "token"}, + ) + access_token = token_data["access_token"] if token_cache: @@ -345,11 +515,14 @@ def exchange_user_token( credentials: CustomerCredentials, user_token: str, timeout: float, - app_tid: str | None = None, token_cache: _TokenCache | None = None, ) -> str: """Exchange user token for AGW-scoped token using jwt-bearer grant. + Automatically selects authentication mode based on credentials: + - STANDARD mode: Uses mTLS with certificate and private key + - TRANSPARENT mode: Uses standard HTTPS (gateway handles mTLS) + Used for tool invocation where user identity must be preserved for principal propagation. @@ -357,31 +530,47 @@ def exchange_user_token( credentials: Customer credentials. user_token: User's JWT token to exchange. timeout: HTTP timeout in seconds. - app_tid: BTP Application Tenant ID of subscriber (optional). token_cache: Optional token cache used to reuse still-valid exchanged tokens. Returns: AGW-scoped access token with user identity, fetched or served from cache. """ - scope_key = _cache_scope_key(credentials, app_tid) + scope_key = _cache_scope_key(credentials) if token_cache: cached_token = token_cache.get_user_token(user_token, scope_key) if cached_token: logger.debug("Using cached exchanged user token for scope '%s'", scope_key) return cached_token - logger.info("Exchanging user token for AGW-scoped token via jwt-bearer grant") - token_data = _request_token_mtls( - credentials, - grant_type=_GRANT_TYPE_JWT_BEARER, - timeout=timeout, - app_tid=app_tid, - extra_data={ - "assertion": user_token, - "token_format": "jwt", - }, - ) + # Determine which token request method to use based on credentials + if credentials.certificate and credentials.private_key: + # STANDARD mode: mTLS authentication + logger.info("Exchanging user token for AGW-scoped token via jwt-bearer grant") + token_data = _request_token_mtls( + credentials, + grant_type=_GRANT_TYPE_JWT_BEARER, + timeout=timeout, + extra_data={ + "assertion": user_token, + "token_format": "jwt", + }, + ) + else: + # TRANSPARENT mode: standard HTTPS authentication + logger.info( + "Exchanging user token for AGW-scoped token via transparent jwt-bearer grant" + ) + token_data = _request_token_transparent( + credentials, + grant_type=_GRANT_TYPE_JWT_BEARER, + timeout=timeout, + extra_data={ + "assertion": user_token, + "token_format": "jwt", + }, + ) + access_token = token_data["access_token"] if token_cache: @@ -420,7 +609,6 @@ def _build_mcp_url(gateway_url: str, ord_id: str, gt_id: str) -> str: async def _list_server_tools( url: str, auth_token: str, - dependency: IntegrationDependency, timeout: float, ) -> list[MCPTool]: """List tools from a single MCP server. @@ -518,7 +706,7 @@ async def get_mcp_tools_customer( ) try: - server_tools = await _list_server_tools(url, system_token, dep, timeout) + server_tools = await _list_server_tools(url, system_token, timeout) tools.extend(server_tools) logger.debug("Loaded %d tool(s) from %s", len(server_tools), dep.ord_id) except Exception: diff --git a/src/sap_cloud_sdk/agentgateway/_dependencies_resolver.py b/src/sap_cloud_sdk/agentgateway/_dependencies_resolver.py new file mode 100644 index 00000000..f0df2025 --- /dev/null +++ b/src/sap_cloud_sdk/agentgateway/_dependencies_resolver.py @@ -0,0 +1,99 @@ +"""Integration dependencies resolution for Agent Gateway. + +Provides an abstraction for loading integration dependencies from different sources +(environment variables, files, remote services, etc.). +""" + +import json +import logging +import os +from abc import ABC, abstractmethod + +from sap_cloud_sdk.agentgateway._models import IntegrationDependency +from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError + +logger = logging.getLogger(__name__) + +# Environment variable for integration dependencies +_INTEGRATION_DEPENDENCIES_ENV = "INTEGRATION_DEPENDENCIES" + + +class IntegrationDependenciesResolver(ABC): + """Abstract interface for resolving integration dependencies. + + Integration dependencies define the MCP servers that an agent should + connect to. This abstraction allows different sources for this configuration. + """ + + @abstractmethod + def resolve(self) -> list[IntegrationDependency]: + """Resolve integration dependencies from configured source. + + Returns: + List of IntegrationDependency objects with ord_id and global_tenant_id. + + Raises: + AgentGatewaySDKError: If resolution fails or configuration is invalid. + """ + pass + + +class EnvironmentDependenciesResolver(IntegrationDependenciesResolver): + """Resolves integration dependencies from INTEGRATION_DEPENDENCIES environment variable. + + Expected format is a JSON array: + [ + { + "ordId": "sap.example:apiResource:demo:v1", + "globalTenantId": "123456" + } + ] + """ + + def resolve(self) -> list[IntegrationDependency]: + """Load integration dependencies from INTEGRATION_DEPENDENCIES env var. + + Returns: + List of IntegrationDependency objects. + + Raises: + AgentGatewaySDKError: If environment variable is missing or invalid. + """ + raw_value = os.environ.get(_INTEGRATION_DEPENDENCIES_ENV) + + if not raw_value: + raise AgentGatewaySDKError( + f"Missing required environment variable: {_INTEGRATION_DEPENDENCIES_ENV}. " + 'Expected format: [{"ordId": "...", "globalTenantId": "..."}]' + ) + + try: + data = json.loads(raw_value) + except json.JSONDecodeError as e: + raise AgentGatewaySDKError( + f"Failed to parse {_INTEGRATION_DEPENDENCIES_ENV} as JSON: {e}" + ) from e + + if not isinstance(data, list): + raise AgentGatewaySDKError( + f"{_INTEGRATION_DEPENDENCIES_ENV} must be a JSON array, got: {type(data).__name__}" + ) + + try: + dependencies = [ + IntegrationDependency( + ord_id=dep["ordId"], + global_tenant_id=dep["globalTenantId"], + ) + for dep in data + ] + logger.debug( + "Loaded %d integration dependencies from environment", + len(dependencies), + ) + return dependencies + except (KeyError, TypeError) as e: + raise AgentGatewaySDKError( + f"Invalid format in {_INTEGRATION_DEPENDENCIES_ENV}: {e}. " + 'Expected format: [{"ordId": "...", "globalTenantId": "..."}]' + ) from e diff --git a/src/sap_cloud_sdk/agentgateway/_models.py b/src/sap_cloud_sdk/agentgateway/_models.py index 8e621bf0..7cfa3b74 100644 --- a/src/sap_cloud_sdk/agentgateway/_models.py +++ b/src/sap_cloud_sdk/agentgateway/_models.py @@ -72,26 +72,28 @@ class IntegrationDependency: @dataclass class CustomerCredentials: - """Credentials for customer agent mTLS authentication. + """Credentials for customer agent authentication. - Loaded from the credentials file mounted on the pod filesystem. + Loaded from the credentials file mounted on the pod filesystem (STANDARD mode) + or from environment variables (TRANSPARENT mode). Used internally by the customer agent flow. Attributes: token_service_url: IAS token service endpoint URL client_id: IAS client ID - certificate: PEM-encoded client certificate - private_key: PEM-encoded private key + certificate: PEM-encoded client certificate (required for STANDARD mode, None for TRANSPARENT) + private_key: PEM-encoded private key (required for STANDARD mode, None for TRANSPARENT) gateway_url: Agent Gateway base URL integration_dependencies: List of MCP servers with their ord_id and global_tenant_id. + tls_mode: TLS authentication mode (STANDARD or TRANSPARENT) """ token_service_url: str client_id: str - certificate: str - private_key: str gateway_url: str integration_dependencies: list[IntegrationDependency] + certificate: str | None = None + private_key: str | None = None @dataclass diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 33a0ccfc..1c120adb 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -15,10 +15,12 @@ from sap_cloud_sdk.agentgateway._customer import ( call_mcp_tool_customer, detect_customer_agent_credentials, + detect_transparent_credentials, exchange_user_token, get_mcp_tools_customer, get_system_token_mtls, load_customer_credentials, + load_customer_credentials_from_env, ) from sap_cloud_sdk.agentgateway._lob import ( call_mcp_tool_lob, @@ -36,7 +38,7 @@ ) 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_compat import Module, Operation, record_metrics logger = logging.getLogger(__name__) @@ -153,17 +155,12 @@ def _resolve_tenant_subdomain(self) -> str: ) @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) - async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: + async def get_system_auth(self) -> AuthResult: """Get system-scoped authentication (client_credentials flow). Automatically detects agent type (LoB vs Customer) based on credential file presence. - Args: - app_tid: BTP Application Tenant ID of the subscriber. - Only used for customer agents. This is passed to the token - service for tenant-scoped token requests. - Returns: AuthResult with raw access token (JWT) and Agent Gateway URL. @@ -191,7 +188,6 @@ async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: get_system_token_mtls, credentials, self._config.timeout, - app_tid, self._token_cache, ) return AuthResult( @@ -199,9 +195,22 @@ async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: gateway_url=credentials.gateway_url, ) - # LoB flow - if app_tid: - logger.warning("app_tid parameter ignored for LoB agent flow") + # Check for transparent mode + if detect_transparent_credentials(): + logger.info("Transparent mode credentials detected") + credentials = load_customer_credentials_from_env() + loop = asyncio.get_running_loop() + token = await loop.run_in_executor( + None, + get_system_token_mtls, + credentials, + self._config.timeout, + self._token_cache, + ) + return AuthResult( + access_token=token, + gateway_url=credentials.gateway_url, + ) tenant = self._resolve_tenant_subdomain() token, gateway_url = await fetch_system_auth( @@ -220,8 +229,7 @@ async def get_system_auth(self, app_tid: str | None = None) -> AuthResult: @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_USER_AUTH) async def get_user_auth( self, - user_token: str | Callable[[], str] | None, - app_tid: str | None = None, + user_token: str | Callable[[], str] | None ) -> AuthResult: """Exchange a user token for AGW-scoped authentication (token exchange). @@ -231,9 +239,6 @@ async def get_user_auth( Args: user_token: User's JWT for principal propagation. Can be a string or a callable returning a string. - app_tid: BTP Application Tenant ID of the subscriber. - Only used for customer agents. This is passed to the token - service for tenant-scoped token exchange. Returns: AuthResult with raw access token (JWT, user identity embedded) @@ -269,7 +274,6 @@ async def get_user_auth( credentials, resolved_user_token, self._config.timeout, - app_tid, self._token_cache, ) return AuthResult( @@ -277,9 +281,23 @@ async def get_user_auth( gateway_url=credentials.gateway_url, ) - # LoB flow - if app_tid: - logger.warning("app_tid parameter ignored for LoB agent flow") + # Check for transparent mode + if detect_transparent_credentials(): + logger.info("Transparent mode credentials detected") + credentials = load_customer_credentials_from_env() + loop = asyncio.get_running_loop() + token = await loop.run_in_executor( + None, + exchange_user_token, + credentials, + resolved_user_token, + self._config.timeout, + self._token_cache, + ) + return AuthResult( + access_token=token, + gateway_url=credentials.gateway_url, + ) tenant = self._resolve_tenant_subdomain() token, gateway_url = await fetch_user_auth( @@ -333,8 +351,7 @@ def get_ias_client_id(self) -> str: @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_LIST_MCP_TOOLS) async def list_mcp_tools( self, - user_token: str | Callable[[], str] | None = None, - app_tid: str | None = None, + user_token: str | Callable[[], str] | None = None ) -> list[MCPTool]: """List all MCP tools from MCP servers. @@ -353,8 +370,6 @@ async def list_mcp_tools( user_token: User's JWT for principal propagation. Can be a string or a callable returning a string. If provided, uses user-scoped auth instead of system auth. - app_tid: BTP Application Tenant ID of the subscriber. - Only used for customer agents. Returns: List of MCPTool objects from all MCP servers. @@ -373,6 +388,11 @@ async def list_mcp_tools( ``` """ try: + if user_token: + auth = await self.get_user_auth(user_token) + else: + auth = await self.get_system_auth() + # Check for customer agent credentials credentials_path = detect_customer_agent_credentials() if credentials_path: @@ -380,18 +400,19 @@ async def list_mcp_tools( "Customer agent credentials detected at '%s'", credentials_path ) credentials = load_customer_credentials(credentials_path) - if user_token: - 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( credentials, auth.access_token, self._config.timeout ) - # LoB flow - requires tenant_subdomain - if app_tid: - logger.warning("app_tid parameter ignored for LoB agent flow") + # Check for transparent mode + if detect_transparent_credentials(): + logger.info("Transparent mode credentials detected") + credentials = load_customer_credentials_from_env() + return await get_mcp_tools_customer( + credentials, auth.access_token, self._config.timeout + ) + # LoB flow - requires tenant_subdomain tenant = self._resolve_tenant_subdomain() if user_token: auth = await self.get_user_auth(user_token) @@ -402,7 +423,6 @@ async def list_mcp_tools( ) except AgentGatewaySDKError: - # Re-raise SDK errors as-is raise except Exception as e: logger.exception("Unexpected error during tool discovery") @@ -480,7 +500,6 @@ async def call_mcp_tool( self, tool: MCPTool, user_token: str | Callable[[], str] | None = None, - app_tid: str | None = None, **kwargs, ) -> str: """Invoke an MCP tool. @@ -500,11 +519,6 @@ async def call_mcp_tool( Can be a string or a callable returning a string. Required for LoB agents. Optional for Customer agents (falls back to system token if not provided). - app_tid: BTP Application Tenant ID of the subscriber. - Only used for customer agents. This is passed to the token service - for tenant-scoped token exchange. - TODO: This parameter's requirement is still being clarified with - the IBD team and may be removed if unnecessary. **kwargs: Tool input parameters (passed directly to the tool). Returns: @@ -517,59 +531,58 @@ async def call_mcp_tool( Example: ```python # Note: kwargs are tool-specific input parameters. - # Check tool.input_schema for expected parameters. + tools = await agw_client.list_mcp_tools() + result = await agw_client.call_mcp_tool( tool=tools[0], user_token="user-jwt", - order_id="12345", # example tool-specific parameter + order_id="12345", ) ``` """ try: + # Resolve user_token if provided + if user_token: + auth = await self.get_user_auth(user_token) + else: + auth = await self.get_system_auth() + # Check for customer agent credentials credentials_path = detect_customer_agent_credentials() if credentials_path: - logger.info( + logger.debug( "Customer agent credentials detected at '%s'", credentials_path ) - # Resolve user_token if provided (optional for customer flow) - if user_token: - auth = await self.get_user_auth(user_token, app_tid) - else: - # TODO: IBD workaround - use system token when user_token - # is not available. This bypasses principal propagation. - # Remove this fallback once IBD supports proper user token flow. - logger.warning( - "No user_token provided - using system token for tool " - "invocation. Principal propagation will NOT work." - ) - auth = await self.get_system_auth(app_tid) - return await call_mcp_tool_customer( tool, auth.access_token, self._config.timeout, **kwargs ) - # LoB flow - requires user_token and tenant_subdomain - if app_tid: - logger.warning("app_tid parameter ignored for LoB agent flow") + # Check for transparent mode + if detect_transparent_credentials(): + logger.debug("Transparent mode credentials detected") + + return await call_mcp_tool_customer( + tool, auth.access_token, self._config.timeout, **kwargs + ) - auth = await self.get_user_auth(user_token, app_tid) + auth = await self.get_user_auth(user_token) return await call_mcp_tool_lob( tool, auth.access_token, self._config.timeout, **kwargs ) except AgentGatewaySDKError: - # Re-raise SDK errors as-is raise except Exception as e: logger.exception("Unexpected error during tool invocation") cause = _unwrap_exception_group(e) + tool_label = tool if isinstance(tool, str) else tool.name raise AgentGatewaySDKError( - f"Tool invocation failed for '{tool.name}': {cause}" + f"Tool invocation failed for '{tool_label}': {cause}" ) from e + def _unwrap_exception_group(exc: BaseException) -> BaseException: """Unwrap nested ExceptionGroups to present meaningful error messages.""" while isinstance(exc, BaseExceptionGroup) and exc.exceptions: diff --git a/src/sap_cloud_sdk/agentgateway/exceptions.py b/src/sap_cloud_sdk/agentgateway/exceptions.py index 5d96e21f..b88a017c 100644 --- a/src/sap_cloud_sdk/agentgateway/exceptions.py +++ b/src/sap_cloud_sdk/agentgateway/exceptions.py @@ -22,3 +22,22 @@ class MCPServerNotFoundError(AgentGatewaySDKError): """ pass + + +class AgentGatewayServerError(AgentGatewaySDKError): + """Raised when the Agent Gateway server returns an error response. + + This error occurs when: + - The MCP server card is not found in the registry + - The server returns a JSON-RPC error (e.g. code -32600) + - A tool invocation returns an error result (isError=True) + + Attributes: + error_code: JSON-RPC error code, if available. + server_message: The raw error message from the server. + """ + + def __init__(self, message: str, error_code: int | None = None): + super().__init__(message) + self.error_code = error_code + self.server_message = message diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 686d9f37..e92488c6 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -203,14 +203,12 @@ class AgentGatewayClient: async def list_mcp_tools( self, user_token: str | Callable[[], str] | None = None, - app_tid: str | None = None, ) -> list[MCPTool] async def call_mcp_tool( self, tool: MCPTool, user_token: str | Callable[[], str] | None = None, - app_tid: str | None = None, **kwargs, ) -> str diff --git a/src/sap_cloud_sdk/core/_telemetry_compat.py b/src/sap_cloud_sdk/core/_telemetry_compat.py new file mode 100644 index 00000000..cbc4185f --- /dev/null +++ b/src/sap_cloud_sdk/core/_telemetry_compat.py @@ -0,0 +1,27 @@ +"""Conditional telemetry imports for modules that use telemetry as optional dependency. + +This module provides a centralized way to handle optional telemetry dependencies. +When telemetry packages are not installed, it provides no-op implementations. +""" + +try: + from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + TELEMETRY_AVAILABLE = True +except ImportError: + TELEMETRY_AVAILABLE = False + # Provide no-op implementations when telemetry is not available + Module = None # type: ignore + Operation = None # type: ignore + + def record_metrics(*args, **kwargs): # type: ignore + """No-op decorator when telemetry is not available.""" + def decorator(func): + return func + if args and callable(args[0]): + # Called without parentheses: @record_metrics + return args[0] + # Called with parentheses: @record_metrics(...) + return decorator + + +__all__ = ["Module", "Operation", "record_metrics", "TELEMETRY_AVAILABLE"] diff --git a/src/sap_cloud_sdk/destination/certificate_client.py b/src/sap_cloud_sdk/destination/certificate_client.py index de7589c6..99591198 100644 --- a/src/sap_cloud_sdk/destination/certificate_client.py +++ b/src/sap_cloud_sdk/destination/certificate_client.py @@ -4,7 +4,7 @@ from typing import List, Optional, TypeVar, Callable -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.core._telemetry_compat import Module, Operation, record_metrics from sap_cloud_sdk.destination._http import DestinationHttp, API_V1 from sap_cloud_sdk.destination._models import ( AccessStrategy, diff --git a/src/sap_cloud_sdk/destination/client.py b/src/sap_cloud_sdk/destination/client.py index d4ffd3e0..4339de33 100644 --- a/src/sap_cloud_sdk/destination/client.py +++ b/src/sap_cloud_sdk/destination/client.py @@ -6,7 +6,9 @@ import warnings from typing import Any, Dict, List, Optional, Callable, TypeVar -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +# Conditional telemetry import - works even when telemetry packages are not installed +from sap_cloud_sdk.core._telemetry_compat import Module, Operation, record_metrics + from sap_cloud_sdk.core.secret_resolver import read_from_mount_and_fallback_to_env_var from sap_cloud_sdk.destination._http import DestinationHttp, API_V1, API_V2 from sap_cloud_sdk.destination._models import ( diff --git a/src/sap_cloud_sdk/destination/fragment_client.py b/src/sap_cloud_sdk/destination/fragment_client.py index bd870ef9..a6eed88f 100644 --- a/src/sap_cloud_sdk/destination/fragment_client.py +++ b/src/sap_cloud_sdk/destination/fragment_client.py @@ -4,7 +4,7 @@ from typing import Callable, List, Optional, TypeVar -from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +from sap_cloud_sdk.core._telemetry_compat import Module, Operation, record_metrics from sap_cloud_sdk.destination._http import DestinationHttp, API_V1 from sap_cloud_sdk.destination._models import ( AccessStrategy, diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index 8df9e96a..8669aadd 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -44,6 +44,16 @@ def _client_gateway_url_cache(client: AgentGatewayClient): return getattr(client, "_gateway_url_cache") +@pytest.fixture(autouse=True) +def no_transparent_credentials(): + """Prevent transparent mode from activating in tests that don't opt in.""" + with patch( + "sap_cloud_sdk.agentgateway.agw_client.detect_transparent_credentials", + return_value=False, + ): + yield + + # ============================================================ # Test: create_client factory # ============================================================ @@ -163,14 +173,14 @@ async def test_customer_flow_returns_auth_result(self): agw_client = create_client() token_cache = _client_token_cache(agw_client) - result = await agw_client.get_system_auth(app_tid="test-tid") + result = await agw_client.get_system_auth() assert isinstance(result, AuthResult) 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_creds, 60.0, token_cache ) @pytest.mark.asyncio @@ -281,14 +291,14 @@ async def test_customer_flow_exchanges_token(self): token_cache = _client_token_cache(agw_client) result = await agw_client.get_user_auth( - user_token="user-jwt", app_tid="test-tid" + user_token="user-jwt" ) assert isinstance(result, AuthResult) assert result.access_token == "exchanged-token" assert result.gateway_url == "https://agw.customer.com" mock_exchange.assert_called_once_with( - mock_creds, "user-jwt", 60.0, "test-tid", token_cache + mock_creds, "user-jwt", 60.0, token_cache ) @pytest.mark.asyncio @@ -518,7 +528,7 @@ async def test_customer_flow_passes_system_token(self): agw_client = create_client() - await agw_client.list_mcp_tools(app_tid="tid") + await agw_client.list_mcp_tools() mock_customer.assert_called_once_with( mock_creds, "customer-system-token", 60.0 @@ -547,7 +557,7 @@ async def test_lob_flow_with_user_token_uses_user_auth(self): await agw_client.list_mcp_tools(user_token="user-jwt") - mock_user_auth.assert_called_once() + assert mock_user_auth.call_count == 2 mock_lob.assert_called_once_with("my-tenant", "user-token-xyz", 60.0) @pytest.mark.asyncio @@ -572,7 +582,7 @@ async def test_customer_flow_with_user_token_uses_user_auth(self): agw_client = create_client() - await agw_client.list_mcp_tools(user_token="user-jwt", app_tid="tid") + await agw_client.list_mcp_tools(user_token="user-jwt") mock_customer.assert_called_once_with( mock_creds, "exchanged-user-token", 60.0 @@ -593,6 +603,10 @@ async def test_missing_user_token_raises(self, mock_tool): 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=("system-token", "https://agw.example.com"), ): agw_client = create_client(tenant_subdomain="my-tenant") @@ -605,6 +619,10 @@ async def test_whitespace_user_token_raises(self, mock_tool): 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=("system-token", "https://agw.example.com"), ): agw_client = create_client(tenant_subdomain="my-tenant") diff --git a/tests/agentgateway/unit/test_customer.py b/tests/agentgateway/unit/test_customer.py index 4469ab47..a020fb36 100644 --- a/tests/agentgateway/unit/test_customer.py +++ b/tests/agentgateway/unit/test_customer.py @@ -7,14 +7,17 @@ from sap_cloud_sdk.agentgateway._customer import ( detect_customer_agent_credentials, + detect_transparent_credentials, load_customer_credentials, + load_customer_credentials_from_env, get_system_token_mtls, exchange_user_token, get_mcp_tools_customer, call_mcp_tool_customer, _build_mcp_url, - _CREDENTIALS_PATH_ENV, - _CREDENTIALS_DEFAULT_PATH, + _INTEGRATION_CLIENT_ID_ENV, + _INTEGRATION_AUTH_URL_ENV, + _INTEGRATION_GATEWAY_URL_ENV, ) from sap_cloud_sdk.agentgateway._models import ( CustomerCredentials, @@ -34,62 +37,43 @@ class TestDetectCustomerAgentCredentials: """Tests for customer agent credential detection.""" - def test_detect_from_env_var_path(self, tmp_path): - """Detect credentials from path specified in environment variable.""" - creds_file = tmp_path / "credentials.json" + def test_detect_from_servicebinding_root(self, tmp_path): + """Detect credentials via servicebinding.io scan of SERVICE_BINDING_ROOT.""" + binding_dir = tmp_path / "my-binding" + binding_dir.mkdir() + (binding_dir / "type").write_text("integration-credentials") + creds_file = binding_dir / "credentials" creds_file.write_text('{"clientid": "test"}') - with patch.dict(os.environ, {_CREDENTIALS_PATH_ENV: str(creds_file)}): + with patch( + "sap_cloud_sdk.agentgateway._customer.resolve_base_mount", + return_value=str(tmp_path), + ): result = detect_customer_agent_credentials() assert result == str(creds_file) - def test_detect_from_env_var_path_file_not_exists(self): - """Return None when env var path doesn't exist.""" - with patch.dict(os.environ, {_CREDENTIALS_PATH_ENV: "/nonexistent/path"}): + def test_skips_binding_with_wrong_type(self, tmp_path): + """Return None when binding type does not match integration-credentials.""" + binding_dir = tmp_path / "other-binding" + binding_dir.mkdir() + (binding_dir / "type").write_text("some-other-type") + (binding_dir / "credentials").write_text('{"clientid": "test"}') + + with patch( + "sap_cloud_sdk.agentgateway._customer.resolve_base_mount", + return_value=str(tmp_path), + ): result = detect_customer_agent_credentials() assert result is None - def test_detect_from_default_path(self): - """Detect credentials from default mounted path.""" - with patch.dict(os.environ, {}, clear=False): - # Remove env var if present - os.environ.pop(_CREDENTIALS_PATH_ENV, None) - - with patch("os.path.isfile") as mock_isfile: - mock_isfile.side_effect = lambda p: p == _CREDENTIALS_DEFAULT_PATH - - result = detect_customer_agent_credentials() - assert result == _CREDENTIALS_DEFAULT_PATH - def test_no_credentials_returns_none(self): - """Return None when no credentials are found.""" - with patch.dict(os.environ, {}, clear=False): - os.environ.pop(_CREDENTIALS_PATH_ENV, None) - - with patch("os.path.isfile", return_value=False): - result = detect_customer_agent_credentials() - assert result is None - - def test_env_var_takes_priority_over_default(self, tmp_path): - """Env var path should take priority over default path.""" - creds_file = tmp_path / "custom_credentials.json" - creds_file.write_text('{"clientid": "custom"}') - - with patch.dict(os.environ, {_CREDENTIALS_PATH_ENV: str(creds_file)}): - # Even if default path exists, env var should be used - with patch("os.path.isfile") as mock_isfile: - - def isfile_side_effect(path): - if path == str(creds_file): - return True - if path == _CREDENTIALS_DEFAULT_PATH: - return True - return False - - mock_isfile.side_effect = isfile_side_effect - - result = detect_customer_agent_credentials() - assert result == str(creds_file) + """Return None when SERVICE_BINDING_ROOT does not exist.""" + with patch( + "sap_cloud_sdk.agentgateway._customer.resolve_base_mount", + return_value=None, + ): + result = detect_customer_agent_credentials() + assert result is None # ============================================================ @@ -112,7 +96,7 @@ def test_loads_valid_credentials(self, tmp_path): "integrationDependencies": [ { "ordId": "sap.test:apiResource:demo:v1", - "data": {"globalTenantId": "123"}, + "globalTenantId": "123", }, ], } @@ -172,11 +156,11 @@ def test_loads_integration_dependencies(self, tmp_path): "integrationDependencies": [ { "ordId": "sap.mcpbuilder:apiResource:cost-center:v1", - "data": {"globalTenantId": "250695"}, + "globalTenantId": "250695", }, { "ordId": "sap.flights:mcpServer:v1", - "data": {"globalTenantId": "892451733"}, + "globalTenantId": "892451733", }, ], } @@ -194,6 +178,30 @@ def test_loads_integration_dependencies(self, tmp_path): assert result.integration_dependencies[1].ord_id == "sap.flights:mcpServer:v1" assert result.integration_dependencies[1].global_tenant_id == "892451733" + def test_loads_integration_dependencies_flat_format(self, tmp_path): + """Load integrationDependencies in flat format (globalTenantId at top level).""" + creds_file = tmp_path / "credentials.json" + creds_data = { + "tokenServiceUrl": "https://ias.example.com/oauth2/token", + "clientid": "my-client-id", + "certificate": "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + "privateKey": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "gatewayUrl": "https://agw.example.com", + "integrationDependencies": [ + { + "ordId": "sap.s4:apiResource:API_PRODUCT:v1", + "globalTenantId": "731473562", + }, + ], + } + creds_file.write_text(json.dumps(creds_data)) + + result = load_customer_credentials(str(creds_file)) + + assert len(result.integration_dependencies) == 1 + assert result.integration_dependencies[0].ord_id == "sap.s4:apiResource:API_PRODUCT:v1" + assert result.integration_dependencies[0].global_tenant_id == "731473562" + def test_raises_when_integration_dependencies_missing(self, tmp_path): """Raise error when integrationDependencies is not in credentials file.""" creds_file = tmp_path / "credentials.json" @@ -355,41 +363,6 @@ def test_reuses_cached_system_token(self, credentials): assert second == "system-token-123" mock_request.assert_called_once() - def test_scopes_system_token_cache_by_app_tid(self, credentials): - """Keep app-tid-specific system tokens isolated in the cache.""" - token_cache = _TokenCache(ClientConfig()) - - with patch( - "sap_cloud_sdk.agentgateway._customer._request_token_mtls", - side_effect=[ - {"access_token": "token-tid-1", "expires_in": 300}, - {"access_token": "token-tid-2", "expires_in": 300}, - ], - ) as mock_request: - first = get_system_token_mtls( - credentials, - timeout=60.0, - app_tid="tid-1", - token_cache=token_cache, - ) - second = get_system_token_mtls( - credentials, - timeout=60.0, - app_tid="tid-1", - token_cache=token_cache, - ) - third = get_system_token_mtls( - credentials, - timeout=60.0, - app_tid="tid-2", - token_cache=token_cache, - ) - - assert first == "token-tid-1" - assert second == "token-tid-1" - assert third == "token-tid-2" - assert mock_request.call_count == 2 - # ============================================================ # Test: exchange_user_token @@ -439,34 +412,6 @@ def test_exchanges_user_token_with_jwt_bearer(self, credentials): assert data["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" assert data["assertion"] == "user-jwt-token" - def test_passes_app_tid_when_provided(self, credentials): - """Include app_tid in request when provided.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"access_token": "token-with-tid"} - - with ( - patch( - "sap_cloud_sdk.agentgateway._customer._create_ssl_context" - ) as mock_ssl, - patch("httpx.Client") as mock_client_class, - ): - mock_ssl.return_value = MagicMock() - mock_client = MagicMock() - mock_client.__enter__ = MagicMock(return_value=mock_client) - mock_client.__exit__ = MagicMock(return_value=False) - mock_client.post.return_value = mock_response - mock_client_class.return_value = mock_client - - result = exchange_user_token( - credentials, "user-jwt", timeout=60.0, app_tid="test-tid" - ) - - assert result == "token-with-tid" - call_args = mock_client.post.call_args - data = call_args.kwargs.get("data", {}) - assert data["app_tid"] == "test-tid" - def test_reuses_cached_user_token(self, credentials): """Reuse exchanged user token until it expires.""" token_cache = _TokenCache(ClientConfig()) @@ -741,3 +686,332 @@ async def test_returns_empty_string_when_no_content(self, credentials, mock_tool ) assert result == "" + + +# ============================================================ +# Test: detect_transparent_credentials +# ============================================================ + + +class TestDetectTransparentCredentials: + """Tests for transparent mode credential detection.""" + + def test_detects_when_all_env_vars_present(self): + """Detect transparent mode when all required environment variables are set.""" + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client", + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com", + }, + ): + result = detect_transparent_credentials() + assert result is True + + def test_returns_false_when_client_id_missing(self): + """Return False when INTEGRATION_CLIENT_ID is missing.""" + with patch.dict( + os.environ, + { + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com", + }, + clear=False, + ): + os.environ.pop(_INTEGRATION_CLIENT_ID_ENV, None) + result = detect_transparent_credentials() + assert result is False + + def test_returns_false_when_token_service_url_missing(self): + """Return False when INTEGRATION_TOKEN_SERVICE_URL is missing.""" + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com", + }, + clear=False, + ): + os.environ.pop(_INTEGRATION_AUTH_URL_ENV, None) + result = detect_transparent_credentials() + assert result is False + + def test_returns_false_when_gateway_url_missing(self): + """Return False when INTEGRATION_GATEWAY_URL is missing.""" + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client", + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + }, + clear=False, + ): + os.environ.pop(_INTEGRATION_GATEWAY_URL_ENV, None) + result = detect_transparent_credentials() + assert result is False + + def test_returns_false_when_all_missing(self): + """Return False when all environment variables are missing.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop(_INTEGRATION_CLIENT_ID_ENV, None) + os.environ.pop(_INTEGRATION_AUTH_URL_ENV, None) + os.environ.pop(_INTEGRATION_GATEWAY_URL_ENV, None) + result = detect_transparent_credentials() + assert result is False + + +# ============================================================ +# Test: load_customer_credentials_from_env +# ============================================================ + + +class TestLoadCustomerCredentialsFromEnv: + """Tests for loading credentials from environment variables (transparent mode).""" + + def test_loads_valid_credentials_from_env(self): + """Load credentials from environment variables.""" + deps_json = json.dumps( + [ + { + "ordId": "sap.example:apiResource:demo:v1", + "globalTenantId": "123456", + } + ] + ) + + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client-id", + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com/", + "INTEGRATION_DEPENDENCIES": deps_json, + }, + ): + result = load_customer_credentials_from_env() + + assert result.client_id == "test-client-id" + assert result.token_service_url == "https://ias.example.com/oauth2/token" + assert result.gateway_url == "https://agw.example.com" + assert result.certificate is None + assert result.private_key is None + assert len(result.integration_dependencies) == 1 + assert result.integration_dependencies[0].ord_id == "sap.example:apiResource:demo:v1" + assert result.integration_dependencies[0].global_tenant_id == "123456" + + def test_raises_when_client_id_missing(self): + """Raise error when INTEGRATION_CLIENT_ID is missing.""" + deps_json = json.dumps([{"ordId": "test", "globalTenantId": "123"}]) + + with patch.dict( + os.environ, + { + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com", + "INTEGRATION_DEPENDENCIES": deps_json, + }, + clear=False, + ): + os.environ.pop(_INTEGRATION_CLIENT_ID_ENV, None) + + with pytest.raises( + AgentGatewaySDKError, + match="Transparent TLS mode requires environment variables", + ): + load_customer_credentials_from_env() + + def test_raises_when_token_service_url_missing(self): + """Raise error when INTEGRATION_TOKEN_SERVICE_URL is missing.""" + deps_json = json.dumps([{"ordId": "test", "globalTenantId": "123"}]) + + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com", + "INTEGRATION_DEPENDENCIES": deps_json, + }, + clear=False, + ): + os.environ.pop(_INTEGRATION_AUTH_URL_ENV, None) + + with pytest.raises( + AgentGatewaySDKError, + match="Transparent TLS mode requires environment variables", + ): + load_customer_credentials_from_env() + + def test_raises_when_gateway_url_missing(self): + """Raise error when INTEGRATION_GATEWAY_URL is missing.""" + deps_json = json.dumps([{"ordId": "test", "globalTenantId": "123"}]) + + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client", + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + "INTEGRATION_DEPENDENCIES": deps_json, + }, + clear=False, + ): + os.environ.pop(_INTEGRATION_GATEWAY_URL_ENV, None) + + with pytest.raises( + AgentGatewaySDKError, + match="Transparent TLS mode requires environment variables", + ): + load_customer_credentials_from_env() + + def test_raises_when_dependencies_missing(self): + """Raise error when INTEGRATION_DEPENDENCIES is missing.""" + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client", + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com", + }, + clear=False, + ): + os.environ.pop("INTEGRATION_DEPENDENCIES", None) + + with pytest.raises( + AgentGatewaySDKError, + match="Missing required environment variable: INTEGRATION_DEPENDENCIES", + ): + load_customer_credentials_from_env() + + def test_uses_custom_dependencies_resolver(self): + """Use custom dependencies resolver when provided.""" + from sap_cloud_sdk.agentgateway._dependencies_resolver import ( + IntegrationDependenciesResolver, + ) + + class CustomResolver(IntegrationDependenciesResolver): + def resolve(self): + return [ + IntegrationDependency( + ord_id="custom.ord:id:v1", + global_tenant_id="999", + ) + ] + + with patch.dict( + os.environ, + { + _INTEGRATION_CLIENT_ID_ENV: "test-client", + _INTEGRATION_AUTH_URL_ENV: "https://ias.example.com/oauth2/token", + _INTEGRATION_GATEWAY_URL_ENV: "https://agw.example.com", + }, + ): + result = load_customer_credentials_from_env( + dependencies_resolver=CustomResolver() + ) + + assert len(result.integration_dependencies) == 1 + assert result.integration_dependencies[0].ord_id == "custom.ord:id:v1" + + +# ============================================================ +# Test: Transparent mode token requests +# ============================================================ + + +class TestTransparentModeTokenRequests: + """Tests for token requests in transparent mode.""" + + @pytest.fixture + def transparent_credentials(self): + """Create test credentials for transparent mode.""" + return CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + gateway_url="https://agw.example.com", + integration_dependencies=[], + certificate=None, + private_key=None, + ) + + def test_system_token_uses_transparent_mode(self, transparent_credentials): + """Request system token using transparent mode (no mTLS).""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "transparent-system-token"} + + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + result = get_system_token_mtls(transparent_credentials, timeout=60.0) + + assert result == "transparent-system-token" + # Verify no SSL context was created (transparent mode) + mock_client_class.assert_called_once() + call_kwargs = mock_client_class.call_args.kwargs + assert "verify" not in call_kwargs or call_kwargs["verify"] is not None + + def test_user_token_exchange_uses_transparent_mode(self, transparent_credentials): + """Exchange user token using transparent mode (no mTLS).""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "transparent-user-token"} + + with patch("httpx.Client") as mock_client_class: + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + result = exchange_user_token( + transparent_credentials, "user-jwt", timeout=60.0 + ) + + assert result == "transparent-user-token" + # Verify jwt-bearer grant was used + call_args = mock_client.post.call_args + data = call_args.kwargs.get("data", {}) + assert data["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" + assert data["assertion"] == "user-jwt" + + @pytest.fixture + def mtls_credentials(self): + """Create test credentials for STANDARD mode (with mTLS).""" + return CustomerCredentials( + token_service_url="https://ias.example.com/oauth2/token", + client_id="test-client", + gateway_url="https://agw.example.com", + integration_dependencies=[], + certificate="-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + private_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + ) + + def test_system_token_uses_mtls_mode(self, mtls_credentials): + """Request system token using STANDARD mode (with mTLS).""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "mtls-system-token"} + + with ( + patch( + "sap_cloud_sdk.agentgateway._customer._create_ssl_context" + ) as mock_ssl, + patch("httpx.Client") as mock_client_class, + ): + mock_ssl.return_value = MagicMock() + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_class.return_value = mock_client + + result = get_system_token_mtls(mtls_credentials, timeout=60.0) + + assert result == "mtls-system-token" + # Verify SSL context was created (STANDARD mode) + mock_ssl.assert_called_once() + diff --git a/tests/agentgateway/unit/test_dependencies_resolver.py b/tests/agentgateway/unit/test_dependencies_resolver.py new file mode 100644 index 00000000..f4e7804b --- /dev/null +++ b/tests/agentgateway/unit/test_dependencies_resolver.py @@ -0,0 +1,142 @@ +"""Unit tests for integration dependencies resolver.""" + +import json +import os +import pytest +from unittest.mock import patch + +from sap_cloud_sdk.agentgateway._dependencies_resolver import ( + EnvironmentDependenciesResolver, + IntegrationDependenciesResolver, + _INTEGRATION_DEPENDENCIES_ENV, +) +from sap_cloud_sdk.agentgateway._models import IntegrationDependency +from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError + + +class TestEnvironmentDependenciesResolver: + """Tests for EnvironmentDependenciesResolver.""" + + def test_resolves_valid_dependencies(self): + """Resolve dependencies from valid JSON in environment variable.""" + deps_json = json.dumps( + [ + { + "ordId": "sap.example:apiResource:demo:v1", + "globalTenantId": "123456", + }, + { + "ordId": "sap.flights:mcpServer:v1", + "globalTenantId": "789012", + }, + ] + ) + + with patch.dict(os.environ, {_INTEGRATION_DEPENDENCIES_ENV: deps_json}): + resolver = EnvironmentDependenciesResolver() + result = resolver.resolve() + + assert len(result) == 2 + assert result[0].ord_id == "sap.example:apiResource:demo:v1" + assert result[0].global_tenant_id == "123456" + assert result[1].ord_id == "sap.flights:mcpServer:v1" + assert result[1].global_tenant_id == "789012" + + def test_raises_when_env_var_missing(self): + """Raise error when environment variable is not set.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop(_INTEGRATION_DEPENDENCIES_ENV, None) + + resolver = EnvironmentDependenciesResolver() + with pytest.raises( + AgentGatewaySDKError, match="Missing required environment variable" + ): + resolver.resolve() + + def test_raises_on_invalid_json(self): + """Raise error when environment variable contains invalid JSON.""" + with patch.dict( + os.environ, {_INTEGRATION_DEPENDENCIES_ENV: "not valid json"} + ): + resolver = EnvironmentDependenciesResolver() + with pytest.raises(AgentGatewaySDKError, match="Failed to parse.*as JSON"): + resolver.resolve() + + def test_raises_when_not_array(self): + """Raise error when JSON is not an array.""" + with patch.dict( + os.environ, {_INTEGRATION_DEPENDENCIES_ENV: json.dumps({"key": "value"})} + ): + resolver = EnvironmentDependenciesResolver() + with pytest.raises( + AgentGatewaySDKError, match="must be a JSON array" + ): + resolver.resolve() + + def test_raises_on_missing_ord_id(self): + """Raise error when dependency is missing ordId field.""" + deps_json = json.dumps( + [ + { + "globalTenantId": "123456", + # Missing ordId + }, + ] + ) + + with patch.dict(os.environ, {_INTEGRATION_DEPENDENCIES_ENV: deps_json}): + resolver = EnvironmentDependenciesResolver() + with pytest.raises(AgentGatewaySDKError, match="Invalid format"): + resolver.resolve() + + def test_raises_on_missing_global_tenant_id(self): + """Raise error when dependency is missing globalTenantId.""" + deps_json = json.dumps( + [ + { + "ordId": "sap.example:apiResource:demo:v1", + # Missing globalTenantId + }, + ] + ) + + with patch.dict(os.environ, {_INTEGRATION_DEPENDENCIES_ENV: deps_json}): + resolver = EnvironmentDependenciesResolver() + with pytest.raises(AgentGatewaySDKError, match="Invalid format"): + resolver.resolve() + + def test_handles_empty_array(self): + """Handle empty array of dependencies.""" + with patch.dict(os.environ, {_INTEGRATION_DEPENDENCIES_ENV: "[]"}): + resolver = EnvironmentDependenciesResolver() + result = resolver.resolve() + + assert result == [] + + +class TestIntegrationDependenciesResolverInterface: + """Tests for IntegrationDependenciesResolver abstract interface.""" + + def test_cannot_instantiate_abstract_class(self): + """Cannot instantiate abstract base class directly.""" + with pytest.raises(TypeError): + IntegrationDependenciesResolver() + + def test_custom_resolver_implementation(self): + """Can create custom resolver implementation.""" + + class CustomResolver(IntegrationDependenciesResolver): + def resolve(self) -> list[IntegrationDependency]: + return [ + IntegrationDependency( + ord_id="custom.ord:id:v1", + global_tenant_id="999", + ) + ] + + resolver = CustomResolver() + result = resolver.resolve() + + assert len(result) == 1 + assert result[0].ord_id == "custom.ord:id:v1" + assert result[0].global_tenant_id == "999" diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 53b1c34f..5ec781c5 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -28,9 +28,8 @@ from sap_cloud_sdk.agentgateway._models import Agent, AgentCard, MCPTool from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig -from sap_cloud_sdk.destination import ConsumptionOptions, ConsumptionLevel from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError, MCPServerNotFoundError -from sap_cloud_sdk.destination import ConsumptionLevel +from sap_cloud_sdk.destination import ConsumptionLevel, ConsumptionOptions # Aliases for use in existing test assertions _LABEL_KEY = LABEL_KEY @@ -1066,3 +1065,4 @@ def test_raises_when_landscape_env_not_set(self): with patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")): with pytest.raises(EnvironmentError, match="APPFND_CONHOS_LANDSCAPE"): get_ias_client_id_lob() +