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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.27.0"
version = "0.28.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
150 changes: 142 additions & 8 deletions src/sap_cloud_sdk/core/auditlog_ng/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
ready-to-use AuditClient.

Usage:
explicit config:

from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig

config = AuditLogNGConfig(
Expand All @@ -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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

destination_instance could be optional also and we can use default by default

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe on agw we don't have destination name and destination_instance configuration, since this is padronized on runtime.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you think we should remove these parameters and consider them by default?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right now, the only "mandatory" parameter is destination_name if endpoint, namespace and development_id are not passed. Do you guys think it works this way?

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 (
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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,
Expand Down
54 changes: 51 additions & 3 deletions src/sap_cloud_sdk/core/auditlog_ng/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

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