diff --git a/pyproject.toml b/pyproject.toml index 725e69c..b297bd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.29.0" +version = "0.29.1" 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 7419850..5273d00 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -21,16 +21,28 @@ client = create_client(config=config) Usage: - resolve from a Destination: + resolve from a Destination (requires tenant): from sap_cloud_sdk.core.auditlog_ng import create_client client = create_client( + tenant="my-tenant-subdomain", destination_name="my-audit-destination", destination_instance="my-binding-instance", fragment_name="prod-fragment", # optional ) +Usage: + explicit keyword arguments: + + from sap_cloud_sdk.core.auditlog_ng import create_client + + client = create_client( + endpoint="audit.example.com:443", + deployment_id="my-deployment", + namespace="namespace-123", + ) + # Send an audit event (protobuf message) event_id = client.send(event, "DataAccess") client.close() @@ -67,6 +79,7 @@ def _get_config_from_destination( destination_name: Optional[str], destination_instance: Optional[str], fragment_name: Optional[str] = None, + tenant: Optional[str] = None, ) -> dict[str, str]: """Resolve endpoint, deployment_id and namespace from a named Destination. @@ -84,16 +97,16 @@ def _get_config_from_destination( passed as ``instance=`` to ``destination.create_client()``. fragment_name: Optional fragment name merged into the destination before resolution. Wrapped in ``ConsumptionOptions`` when provided. + tenant: Tenant subdomain forwarded as ``tenant=`` to + ``get_destination()``. Returns: dict with keys ``endpoint``, ``deployment_id``, ``namespace`` - when destination is found. + when destination is found, or an empty dict when not found. - Returns: - None: If destination is not found. - - Return: - ValueError: If required properties are missing. + Raises: + ValueError: If required properties (``deploymentId``/``deploymentRegion`` + or ``namespace``) are missing from the resolved destination. """ # Lazy import — keeps destination an optional dependency; importing auditlog_ng # in environments without the destination package continues to work. @@ -113,7 +126,10 @@ def _get_config_from_destination( ) destination = dest_client.get_destination( - name=destination_name, options=options, level=ConsumptionLevel.SUBACCOUNT + name=destination_name, + options=options, + tenant=tenant, + level=ConsumptionLevel.SUBACCOUNT, ) if destination is None: @@ -153,6 +169,7 @@ def create_client( destination_name: Optional[str] = "AuditLogV3_Destination", destination_instance: Optional[str] = "default", fragment_name: Optional[str] = None, + tenant: Optional[str] = None, # Explicit connection parameters endpoint: Optional[str] = None, deployment_id: Optional[str] = None, @@ -174,28 +191,35 @@ def create_client( 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. + 2. **Destination-based resolution** — pass ``tenant`` (required to activate + this path). ``destination_name`` and ``destination_instance`` identify + the destination; ``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. + and ``namespace`` directly (used when ``tenant`` is not provided). Args: _telemetry_source: Internal parameter for telemetry. Not for external use. config: Optional explicit configuration. If provided, all other keyword arguments are ignored. - destination_name: Name of the SAP Destination to resolve. Must be - combined with ``destination_instance`` to enter the destination path. + tenant: Tenant subdomain used for destination-based resolution. + When provided, ``destination_name`` and ``destination_instance`` + are used to look up the destination. + destination_name: Name of the SAP Destination to resolve. Only used + when ``tenant`` is provided. 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. + as ``instance=`` to ``destination.create_client()``. Only used + when ``tenant`` is provided. fragment_name: Optional destination fragment name merged before resolution. - endpoint: OTLP gRPC endpoint (``host:port``). - deployment_id: Deployment identifier. - namespace: Namespace identifier. + endpoint: OTLP endpoint (``host:port``). Required when ``tenant`` + is not provided and ``config`` is not given. + deployment_id: Deployment identifier. Required when ``tenant`` is not + provided and ``config`` is not given. + namespace: Namespace identifier. Required when ``tenant`` is not + provided and ``config`` is not given. cert_file: Path to client certificate (PEM) for mTLS. key_file: Path to client private key (PEM) for mTLS. ca_file: Path to CA certificate (PEM) for server verification. @@ -216,26 +240,28 @@ def create_client( try: if config is None: try: - resolved = _get_config_from_destination( - destination_name=destination_name, - destination_instance=destination_instance, - fragment_name=fragment_name, - ) - if resolved: - endpoint = resolved["endpoint"] - deployment_id = resolved["deployment_id"] - namespace = resolved["namespace"] + if tenant: + resolved = _get_config_from_destination( + destination_name=destination_name, + destination_instance=destination_instance, + fragment_name=fragment_name, + tenant=tenant, + ) + if resolved: + 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 or valid destination is not provided" + "when config or valid tenant subdomain is not provided" ) config = AuditLogNGConfig( - endpoint=endpoint, - deployment_id=deployment_id, - namespace=namespace, + endpoint=endpoint or "", + deployment_id=deployment_id or "", + namespace=namespace or "", cert_file=cert_file, key_file=key_file, ca_file=ca_file, 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 689235d..14a303d 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -31,11 +31,7 @@ def test_create_client_with_config(self, mock_provider_cls, mock_exporter_fn): @patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_with_keyword_args(self, _mock_dest, mock_provider_cls, mock_exporter_fn): + def test_create_client_with_keyword_args(self, mock_provider_cls, mock_exporter_fn): mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -49,11 +45,7 @@ def test_create_client_with_keyword_args(self, _mock_dest, mock_provider_cls, mo assert isinstance(client, AuditClient) - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_missing_endpoint_raises(self, _mock_dest): + def test_create_client_missing_endpoint_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(deployment_id="dep-1", namespace="ns-1") @@ -74,11 +66,7 @@ def test_create_client_missing_endpoint_raises(self, _mock_dest): ), ], ) - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_config_errors_record_error_metric(self, _mock_dest, kwargs, match): + def test_create_client_config_errors_record_error_metric(self, kwargs, match): with patch( "sap_cloud_sdk.core.auditlog_ng._record_error_metric" ) as mock_error_metric: @@ -97,35 +85,19 @@ def test_create_client_config_errors_record_error_metric(self, _mock_dest, kwarg Operation.AUDITLOG_CREATE_CLIENT, ) - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_missing_deployment_id_raises(self, _mock_dest): + def test_create_client_missing_deployment_id_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(endpoint="localhost:4317", namespace="ns-1") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_missing_namespace_raises(self, _mock_dest): + def test_create_client_missing_namespace_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(endpoint="localhost:4317", deployment_id="dep-1") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_no_args_raises(self, _mock_dest): + def test_create_client_no_args_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client() - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_invalid_deployment_id_raises(self, _mock_dest): + def test_create_client_invalid_deployment_id_raises(self): with pytest.raises(ValueError, match="deployment_id"): create_client( endpoint="localhost:4317", @@ -171,11 +143,7 @@ def test_create_client_unexpected_exception_wraps_in_client_creation_error( @patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_config_keyword_args_are_forwarded(self, _mock_dest, mock_provider_cls, mock_exporter_fn): + def test_config_keyword_args_are_forwarded(self, mock_provider_cls, mock_exporter_fn): mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -256,14 +224,13 @@ class TestCreateClientFromDestination: # Happy path — destination resolution is always attempted # ------------------------------------------------------------------ - def test_destination_always_attempted_with_defaults( + def test_destination_triggered_by_tenant( self, mock_provider_cls, mock_exporter_fn ): - """_get_config_from_destination is always called, even with no explicit args. + """_get_config_from_destination is called only when tenant is provided. - With defaults destination_name='AuditLogV3_Destination' and - destination_instance='default', create_client() always attempts - destination resolution before falling back to explicit args. + With tenant set and defaults destination_name='AuditLogV3_Destination' and + destination_instance='default', create_client() resolves config from the destination. """ mock_provider = Mock() mock_provider.get_logger.return_value = Mock() @@ -281,17 +248,34 @@ def test_destination_always_attempted_with_defaults( "sap_cloud_sdk.destination.create_client", return_value=dest_client, ) as mock_dest_factory: - client = create_client(insecure=True) + client = create_client(tenant="my-tenant", insecure=True) mock_dest_factory.assert_called_once_with(instance="default") dest_client.get_destination.assert_called_once() call_kwargs = dest_client.get_destination.call_args.kwargs assert call_kwargs["name"] == "AuditLogV3_Destination" + assert call_kwargs["tenant"] == "my-tenant" assert isinstance(client, AuditClient) assert client._config.endpoint == "audit.default.com:443" assert client._config.deployment_id == "dep-default" assert client._config.namespace == "ns-default" + def test_destination_not_triggered_without_tenant( + self, mock_provider_cls, mock_exporter_fn + ): + """_get_config_from_destination is NOT called when tenant is not provided. + + Without a tenant, create_client() skips destination resolution and + requires explicit endpoint/deployment_id/namespace. + """ + with patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination" + ) as mock_get_config: + with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): + create_client(insecure=True) + + mock_get_config.assert_not_called() + def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): """Resolved destination with deploymentId sets endpoint/deployment_id/namespace.""" mock_provider = Mock() @@ -311,6 +295,7 @@ def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -337,6 +322,7 @@ def test_destination_create_client_called_with_instance(self, mock_provider_cls, return_value=dest_client, ) as mock_dest_factory: create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -360,6 +346,7 @@ def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_expor return_value=dest_client, ): create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -377,7 +364,7 @@ def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_expor def test_destination_name_alone_enters_destination_path( self, mock_provider_cls, mock_exporter_fn ): - """destination_name alone enters the destination path (destination_instance + """tenant + destination_name 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() @@ -392,6 +379,7 @@ def test_destination_name_alone_enters_destination_path( return_value=dest_client, ) as mock_dest_factory: client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", insecure=True, ) @@ -402,7 +390,7 @@ def test_destination_name_alone_enters_destination_path( 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 + """tenant + 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() @@ -417,6 +405,7 @@ def test_destination_name_without_fragment_uses_destination_path( return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="inst", insecure=True, @@ -429,7 +418,7 @@ def test_destination_name_without_fragment_uses_destination_path( 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 + """tenant + destination_name with fragment_name but no destination_instance enters the destination path, calling _dest_create_client with instance='default'.""" mock_provider = Mock() mock_provider.get_logger.return_value = Mock() @@ -444,6 +433,7 @@ def test_destination_name_without_instance_uses_default_instance( return_value=dest_client, ) as mock_dest_factory: client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", fragment_name="prod", insecure=True, @@ -476,6 +466,7 @@ def test_fallback_deployment_region_when_deployment_id_missing( return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -504,6 +495,7 @@ def test_fallback_deployment_region_when_deployment_id_empty( return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -530,6 +522,7 @@ def test_missing_both_deployment_props_raises( ): with pytest.raises(ValueError, match="deploymentId.*deploymentRegion"): create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -547,6 +540,7 @@ def test_missing_namespace_raises(self, mock_provider_cls, mock_exporter_fn): ): with pytest.raises(ValueError, match="namespace"): create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -564,13 +558,18 @@ def test_missing_url_propagates_as_endpoint_required(self, mock_provider_cls, mo ): with pytest.raises(ValueError, match="endpoint is required"): create_client( + tenant="my-tenant", 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 and no explicit args are given.""" + """Raises ValueError when get_destination returns None and no explicit args are given. + + When tenant is set but the destination is not found, _get_config_from_destination + returns {} and AuditLogNGConfig is called with endpoint=None, raising 'endpoint is required'. + """ dest_client = MagicMock() dest_client.get_destination.return_value = None @@ -578,8 +577,9 @@ def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn) "sap_cloud_sdk.destination.create_client", return_value=dest_client, ): - with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): + with pytest.raises(ValueError, match="endpoint is required"): create_client( + tenant="my-tenant", destination_name="missing-dest", destination_instance="my-instance", fragment_name="prod",