diff --git a/pyproject.toml b/pyproject.toml index 491412ad..60d58226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.27.0" +version = "0.28.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 321833dd..256a21d3 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -7,6 +7,8 @@ ready-to-use AuditClient. Usage: + explicit config: + from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig config = AuditLogNGConfig( @@ -18,12 +20,24 @@ ) client = create_client(config=config) +Usage: + resolve from a Destination: + + from sap_cloud_sdk.core.auditlog_ng import create_client + + client = create_client( + destination_name="my-audit-destination", + destination_instance="my-binding-instance", + fragment_name="prod-fragment", # optional + ) + # Send an audit event (protobuf message) event_id = client.send(event, "DataAccess") client.close() """ from typing import Optional +from enum import Enum from sap_cloud_sdk.core.auditlog_ng.client import AuditClient from sap_cloud_sdk.core.auditlog_ng.config import ( @@ -43,9 +57,100 @@ ) +class _DestinationProperties(Enum): + DEPLOYMENT_ID = "deploymentId" + DEPLOYMENT_REGION = "deploymentRegion" + NAMESPACE = "namespace" + + +def _get_config_from_destination( + destination_name: str, + destination_instance: Optional[str] = "default", + fragment_name: Optional[str] = None, +) -> dict[str, str]: + """Resolve endpoint, deployment_id and namespace from a named Destination. + + The destination must expose these custom properties: + + - ``deploymentId`` (or ``deploymentRegion`` as fallback when absent/empty) + - ``namespace`` + + The destination ``url`` is used as the OTLP gRPC endpoint. + The lookup is always performed at ``ConsumptionLevel.SUBACCOUNT``. + + Args: + destination_name: Name of the destination to resolve. + destination_instance: Destination service binding instance name, + passed as ``instance=`` to ``destination.create_client()``. + fragment_name: Optional fragment name merged into the destination + before resolution. Wrapped in ``ConsumptionOptions`` when provided. + + Returns: + dict with keys ``endpoint``, ``deployment_id``, ``namespace``. + + Raises: + ValueError: If the destination is not found or required properties + are missing. + """ + # Lazy import — keeps destination an optional dependency; importing auditlog_ng + # in environments without the destination package continues to work. + from sap_cloud_sdk.destination import ( + ConsumptionOptions, + ConsumptionLevel, + create_client as _dest_create_client, + ) + + dest_client = _dest_create_client(instance=destination_instance) + options = ( + ConsumptionOptions( + fragment_name=fragment_name, fragment_level=ConsumptionLevel.SUBACCOUNT + ) + if fragment_name + else None + ) + + destination = dest_client.get_destination( + name=destination_name, options=options, level=ConsumptionLevel.SUBACCOUNT + ) + + if destination is None: + raise ValueError(f"Destination '{destination_name}' was not found") + + endpoint = destination.url + props = destination.properties + + deployment_id = props.get(_DestinationProperties.DEPLOYMENT_ID.value) or "" + if not deployment_id: + deployment_id = props.get(_DestinationProperties.DEPLOYMENT_REGION.value) or "" + if not deployment_id: + raise ValueError( + f"Destination '{destination_name}' must provide either the " + f"'{_DestinationProperties.DEPLOYMENT_ID.value}' or " + f"'{_DestinationProperties.DEPLOYMENT_REGION.value}' property" + ) + + namespace = props.get(_DestinationProperties.NAMESPACE.value) or "" + if not namespace: + raise ValueError( + f"Destination '{destination_name}' must provide the " + f"'{_DestinationProperties.NAMESPACE.value}' property" + ) + + return { + "endpoint": endpoint, + "deployment_id": deployment_id, + "namespace": namespace, + } + + def create_client( *, config: Optional[AuditLogNGConfig] = None, + # Destination-based resolution + destination_name: Optional[str] = None, + destination_instance: Optional[str] = None, + fragment_name: Optional[str] = None, + # Explicit connection parameters endpoint: Optional[str] = None, deployment_id: Optional[str] = None, namespace: Optional[str] = None, @@ -61,13 +166,30 @@ def create_client( ) -> AuditClient: """Create an AuditClient for sending audit events over OTLP/gRPC. - Either pass a pre-built ``config`` **or** the individual keyword arguments. - When ``config`` is provided the remaining keyword arguments are ignored. + Three mutually exclusive ways to provide configuration (evaluated in order): + + 1. **Explicit config object** — pass a pre-built :class:`AuditLogNGConfig` + via ``config``; all other keyword arguments are ignored. + + 2. **Destination-based resolution** — pass ``destination_name`` and + ``destination_instance`` (both required); ``fragment_name`` is optional. + The Destination module resolves the named destination at subaccount level + and extracts ``endpoint``, ``deployment_id`` (with fallback to + ``deploymentRegion``), and ``namespace`` from its properties. + + 3. **Explicit keyword arguments** — pass ``endpoint``, ``deployment_id``, + and ``namespace`` directly. Args: _telemetry_source: Internal parameter for telemetry. Not for external use. config: Optional explicit configuration. If provided, all other - keyword arguments are ignored. + keyword arguments are ignored. + destination_name: Name of the SAP Destination to resolve. Must be + combined with ``destination_instance`` to enter the destination path. + destination_instance: Destination service binding instance name, passed + as ``instance=`` to ``destination.create_client()``. Must be combined + with ``destination_name`` to enter the destination path. + fragment_name: Optional destination fragment name merged before resolution. endpoint: OTLP gRPC endpoint (``host:port``). deployment_id: Deployment identifier. namespace: Namespace identifier. @@ -85,16 +207,28 @@ def create_client( Raises: ClientCreationError: If client creation fails. - ValueError: If required parameters are missing. + ValueError: If required parameters are missing or destination + resolution fails. """ try: if config is None: try: - if not endpoint or not deployment_id or not namespace: - raise ValueError( - "endpoint, deployment_id, and namespace are required " - "when config is not provided" + if destination_name: + resolved = _get_config_from_destination( + destination_name=destination_name, + destination_instance=destination_instance, + fragment_name=fragment_name, ) + endpoint = resolved["endpoint"] + deployment_id = resolved["deployment_id"] + namespace = resolved["namespace"] + else: + if not endpoint or not deployment_id or not namespace: + raise ValueError( + "endpoint, deployment_id, and namespace are required " + "when config is not provided" + ) + config = AuditLogNGConfig( endpoint=endpoint, deployment_id=deployment_id, 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 430ee2dc..f6d58427 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md +++ b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md @@ -12,6 +12,7 @@ The Auditlog NG client sends audit log events as OpenTelemetry (OTLP) LogRecords - **mTLS** (mutual TLS with client certificates) - **Insecure** mode (local testing / no-auth) - **Binary protobuf** and **JSON** serialization formats +- **Destination-based configuration** — resolve connection parameters automatically from a named SAP Destination (SPII-based deployments) --- @@ -36,11 +37,35 @@ The client depends on generated protobuf classes. ## Configuration -All constructor parameters for `AuditClient`: +`create_client` supports three mutually exclusive ways to provide configuration, evaluated in this order: + +1. **Explicit config object** — pass a pre-built `AuditLogNGConfig` via `config=`. +2. **Destination-based resolution** — pass `destination_name` and `destination_instance`; connection parameters are resolved from the named SAP Destination automatically. +3. **Explicit keyword arguments** — pass `endpoint`, `deployment_id`, and `namespace` directly. + +### Destination-based configuration parameters + +| Parameter | Type | Required | Description | +|------------------------|--------|----------|-------------| +| `destination_name` | `str` | ✅ Yes | Name of the SAP Destination to resolve. | +| `destination_instance` | `str` | ❌ No | Destination service binding instance name. | +| `fragment_name` | `str` | ❌ No | Destination fragment merged before resolution (for tenant-specific overrides). | + +The destination must expose these custom properties: + +| Property | Required | Description | +|--------------------|----------|-------------| +| `deploymentId` | ✅ Yes (or `deploymentRegion`) | Deployment identifier. Falls back to `deploymentRegion` when absent or empty. | +| `deploymentRegion` | ✅ Fallback | Used as `deployment_id` when `deploymentId` is missing or empty. | +| `namespace` | ✅ Yes | Audit log namespace (e.g. `sap.als`). | + +The destination `url` is used as the OTLP endpoint. The lookup is always performed at subaccount level. + +### Explicit configuration parameters for `AuditClient`: | Parameter | Type | Required | Default | Description | |-----------------|---------|----------|----------------|-------------------------------------------------------------------------------------------------------| -| `endpoint` | `str` | ✅ Yes | — | OTLP gRPC endpoint of the Audit Log Service (`host:port`) | +| `endpoint` | `str` | ✅ Yes | — | OTLP endpoint of the Audit Log Service (`host:port`) | | `deployment_id` | `str` | ✅ Yes | — | Deployment/region identifier. Validated: only `[a-zA-Z0-9._-/~]` allowed. Raises `ValueError` if invalid. | | `namespace` | `str` | ✅ Yes | — | Audit log namespace (e.g. `sap.als`). Same character-set validation as `deployment_id`. | | `cert_file` | `str` | ❌ No | `None` | Path to the mTLS client certificate file (PEM). Required together with `key_file` for mTLS. | @@ -80,7 +105,30 @@ from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import audite ### Step 2: Initialize the Client -**With mTLS (production):** +**From a Destination (SPII-based deployments):** + +```python +client = create_client( + destination_name="my_destination", + destination_instance="my-destination-binding", + # fragment_name="prod-fragment", # optional: merge a tenant-specific fragment +) +``` + +The SDK resolves `endpoint`, `deployment_id`, and `namespace` from the destination automatically. +You can still pass connection options alongside the destination parameters: + +```python +client = create_client( + destination_name="my_destination", + destination_instance="my-destination-binding", + fragment_name="prod-fragment", + service_name="my-agent", + batch=True, +) +``` + +**With mTLS (explicit configuration):** ```python client = create_client( diff --git a/tests/core/unit/auditlog_ng/unit/test_create_client.py b/tests/core/unit/auditlog_ng/unit/test_create_client.py index 9da5b808..f4bd5cb1 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -1,7 +1,7 @@ """Tests for create_client factory function.""" import pytest -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, MagicMock from sap_cloud_sdk.core.auditlog_ng import create_client, AuditClient from sap_cloud_sdk.core.auditlog_ng.config import AuditLogNGConfig @@ -185,3 +185,371 @@ def test_create_client_records_metric_once_with_source( Operation.AUDITLOG_CREATE_CLIENT, False, ) + + +# --------------------------------------------------------------------------- +# Destination-based resolution +# --------------------------------------------------------------------------- + +def _make_mock_destination( + url="audit.example.com:443", + deployment_id="dep-1", + deployment_region=None, + namespace="ns-1", +): + """Return a mock Destination with the given property values.""" + props = {} + if deployment_id is not None: + props["deploymentId"] = deployment_id + if deployment_region is not None: + props["deploymentRegion"] = deployment_region + if namespace is not None: + props["namespace"] = namespace + + dest = MagicMock() + dest.url = url + dest.properties = props + return dest + + +@patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") +@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") +class TestCreateClientFromDestination: + + # ------------------------------------------------------------------ + # Happy path — all three args required to enter the destination path + # ------------------------------------------------------------------ + + def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): + """Resolved destination with deploymentId sets endpoint/deployment_id/namespace.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination( + url="audit.example.com:443", + deployment_id="dep-1", + namespace="ns-1", + ) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + assert isinstance(client, AuditClient) + assert client._config.endpoint == "audit.example.com:443" + assert client._config.deployment_id == "dep-1" + assert client._config.namespace == "ns-1" + + def test_destination_create_client_called_with_instance(self, mock_provider_cls, mock_exporter_fn): + """destination_instance is forwarded as instance= to _dest_create_client().""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ) as mock_dest_factory: + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + mock_dest_factory.assert_called_once_with(instance="my-instance") + + def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_exporter_fn): + """fragment_name is always wrapped in ConsumptionOptions and forwarded to get_destination.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + from sap_cloud_sdk.destination import ConsumptionLevel + + call_kwargs = dest_client.get_destination.call_args.kwargs + options = call_kwargs.get("options") + assert options is not None + assert options.fragment_name == "prod" + assert options.fragment_level == ConsumptionLevel.SUBACCOUNT + + def test_destination_name_alone_enters_destination_path( + self, mock_provider_cls, mock_exporter_fn + ): + """destination_name alone enters the destination path (destination_instance + defaults to 'default'); the explicit-args guard is NOT raised.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ) as mock_dest_factory: + client = create_client( + destination_name="my-audit-dest", + insecure=True, + ) + + mock_dest_factory.assert_called_once_with(instance=None) + assert isinstance(client, AuditClient) + + def test_destination_name_without_fragment_uses_destination_path( + self, mock_provider_cls, mock_exporter_fn + ): + """destination_name + destination_instance without fragment_name still enters the + destination path; get_destination is called with options=None.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="inst", + insecure=True, + ) + + assert isinstance(client, AuditClient) + call_kwargs = dest_client.get_destination.call_args.kwargs + assert call_kwargs.get("options") is None + + def test_destination_name_without_instance_uses_default_instance( + self, mock_provider_cls, mock_exporter_fn + ): + """destination_name with fragment_name but no destination_instance enters the + destination path, calling _dest_create_client with instance=None.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ) as mock_dest_factory: + client = create_client( + destination_name="my-audit-dest", + fragment_name="prod", + insecure=True, + ) + + mock_dest_factory.assert_called_once_with(instance=None) + assert isinstance(client, AuditClient) + + # ------------------------------------------------------------------ + # deploymentRegion fallback + # ------------------------------------------------------------------ + + def test_fallback_deployment_region_when_deployment_id_missing( + self, mock_provider_cls, mock_exporter_fn + ): + """When deploymentId is absent, deploymentRegion is used as deployment_id.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination( + deployment_id=None, + deployment_region="eu10", + ) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + assert client._config.deployment_id == "eu10" + + def test_fallback_deployment_region_when_deployment_id_empty( + self, mock_provider_cls, mock_exporter_fn + ): + """When deploymentId is an empty string, deploymentRegion is used instead.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination( + deployment_id="", + deployment_region="eu20", + ) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + assert client._config.deployment_id == "eu20" + + # ------------------------------------------------------------------ + # Missing required destination properties + # ------------------------------------------------------------------ + + def test_missing_both_deployment_props_raises( + self, mock_provider_cls, mock_exporter_fn + ): + """Raises ValueError when neither deploymentId nor deploymentRegion is present.""" + dest = _make_mock_destination(deployment_id=None, deployment_region=None) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="deploymentId.*deploymentRegion"): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + def test_missing_namespace_raises(self, mock_provider_cls, mock_exporter_fn): + """Raises ValueError when the namespace property is absent.""" + dest = _make_mock_destination(namespace=None) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="namespace"): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + def test_missing_url_propagates_as_endpoint_required(self, mock_provider_cls, mock_exporter_fn): + """When the destination URL is None, AuditLogNGConfig raises 'endpoint is required'.""" + dest = _make_mock_destination(url=None) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="endpoint is required"): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn): + """Raises ValueError when get_destination returns None.""" + dest_client = MagicMock() + dest_client.get_destination.return_value = None + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="not found"): + create_client( + destination_name="missing-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + # ------------------------------------------------------------------ + # No-destination path is fully preserved (regression) + # ------------------------------------------------------------------ + + def test_no_destination_explicit_args_still_works( + self, mock_provider_cls, mock_exporter_fn + ): + """Existing keyword-arg path is unaffected when destination_name is absent.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + client = create_client( + endpoint="localhost:4317", + deployment_id="dep-1", + namespace="ns-1", + insecure=True, + ) + + assert isinstance(client, AuditClient) + assert client._config.endpoint == "localhost:4317" + + def test_no_destination_config_object_still_works( + self, mock_provider_cls, mock_exporter_fn + ): + """Existing config-object path is unaffected when destination_name is absent.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + config = AuditLogNGConfig( + endpoint="localhost:4317", + deployment_id="dep-1", + namespace="ns-1", + insecure=True, + ) + + client = create_client(config=config) + + assert isinstance(client, AuditClient) diff --git a/uv.lock b/uv.lock index 3d410818..16a53008 100644 --- a/uv.lock +++ b/uv.lock @@ -3696,7 +3696,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.27.0" +version = "0.28.0" source = { editable = "." } dependencies = [ { name = "grpcio" },