Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
df32c68
feat(dpi_ng): add SAP DPI NG consent service module to sdk
Ashwith-N Jun 24, 2026
a618754
chore: bump version to 0.29.0 and update dependencies
Ashwith-N Jun 24, 2026
a3cdf4b
feat(dpi_ng): add telemetry instrumentation to dpi_ng/consent module
Ashwith-N Jun 24, 2026
94e58d0
test(dpi_ng): add telemetry unit tests for consent module operations
Ashwith-N Jun 24, 2026
be669ee
feat(dpi_ng/consent): add DPI_NG_CONSENT_* env vars for integration t…
Ashwith-N Jun 24, 2026
e11aaea
docs(dpi_ng/consent): add integration test setup guide to INTEGRATION…
Ashwith-N Jun 24, 2026
39def69
chore(dpi_ng/consent): align integration test env vars to CLOUD_SDK_C…
Ashwith-N Jun 24, 2026
c7a7dd4
docs(dpi_ng/consent): replace em dashes with hyphens in integration t…
Ashwith-N Jun 24, 2026
7f2e80c
docs(dpi_ng/consent): rewrite user guide with full API examples
Ashwith-N Jun 24, 2026
027a589
fix(dpi_ng/consent): resolve ty type-check errors in auth and client
Ashwith-N Jun 25, 2026
cd67731
docs(dpi_ng/consent): add and improve docstrings across all public APIs
Ashwith-N Jun 25, 2026
0254666
docs(dpi_ng/consent): align docstrings with DPI service router URL an…
Ashwith-N Jun 26, 2026
8f542c4
test(dpi_ng/consent): use shared base URL env var in integration test…
Ashwith-N Jun 30, 2026
5bc8bdd
fix(dpi_ng/consent): add x-tenant-id header support for mTLS client c…
Ashwith-N Jul 2, 2026
09ba46c
docs(dpi_ng/consent): document tenant_id and restructure auth section…
Ashwith-N Jul 2, 2026
c4f4c33
chore(dpi_ng/consent): apply ruff formatting
Ashwith-N Jul 2, 2026
a12cdcb
fix(dpi_ng/consent): validate tenant_id is only accepted for ClientCe…
Ashwith-N Jul 2, 2026
e98f753
docs(dpi_ng/consent): add ConsentSDKConfig parameter table and fix te…
Ashwith-N Jul 2, 2026
8c22066
docs(dpi_ng/consent): fix CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL env v…
Ashwith-N Jul 2, 2026
87756cd
chore(dpi_ng/consent): fix consent user-guide
Ashwith-N Jul 2, 2026
748d3d5
fix(dpi_ng/consent): replace URL substring check with exact equality …
Ashwith-N Jul 6, 2026
3936da2
fix(dpi_ng/consent): simplify error message formatting in config vali…
Ashwith-N Jul 6, 2026
2630974
fix(dpi_ng/consent): correct entity schemas, primary keys, OData quer…
Ashwith-N Jul 9, 2026
d445a7a
chore: bump version to 0.34.0
Ashwith-N Jul 9, 2026
7a4d2d1
fix(dpi_ng/consent): align test assertions, method names, and achieve…
Ashwith-N Jul 9, 2026
3e9b94c
chore(dpi_ng/consent): apply linting and formatting to test files
Ashwith-N Jul 9, 2026
1081cd1
docs(dpi_ng/consent): add mTLS quick-start guide and fix env example …
Ashwith-N Jul 10, 2026
b17e4ac
refactor(dpi_ng): promote shared auth, config, exceptions, and odata_…
Ashwith-N Jul 14, 2026
3be7e83
refactor(dpi_ng): enforce abstract service_path on BaseCapabilityConfig
Ashwith-N Jul 14, 2026
df7844f
chore(dpi_ng): expand ruff and type checks to cover full dpi_ng layer
Ashwith-N Jul 14, 2026
8f25e9c
docs(dpi_ng/consent): fix class name deviations in user-guide.md
Ashwith-N Jul 14, 2026
eaad400
docs(dpi_ng/consent): fix dead test, add unit test command
Ashwith-N Jul 14, 2026
ced5751
chore(dpi_ng/consent): add comment explaining deferred client import
Ashwith-N Jul 15, 2026
fd3c077
Merge remote-tracking branch 'upstream/main'
Ashwith-N Jul 20, 2026
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
18 changes: 18 additions & 0 deletions .env_integration_tests.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_TENANT_SUBDOMAIN=your-subscriber-tenant-subdom
CLOUD_SDK_CFG_SDM_DEFAULT_URI=https://your-sdm-api-uri-here
CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone"}'

# DPI NG - CONSENT
# Required for all consent integration tests
CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL=https://your-dpi-ng-service-host

# Pick ONE of the three auth methods below:

# Option A: Static Bearer Token
# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN=your-bearer-token-here
# Option B: OAuth 2.0 Client Credentials
# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL=https://your-auth-host/oauth/token
# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID=your-client-id
# CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET=your-client-secret
# Option C: Mutual TLS (mTLS)
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE=/path/to/client.crt
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE=/path/to/client.key
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE=/path/to/ca.crt # optional
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TENANT_ID=your-tenant-id-here

# OBJECT STORE
CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_HOST=your-objectstore-host-here
CLOUD_SDK_CFG_OBJECTSTORE_DEFAULT_ACCESS_KEY_ID=your-access-key-id-here
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## About this project

This SDK provides consistent interfaces for interacting with foundational services such as object storage, destination management, audit logging, data anonymization, telemetry, and secure credential handling.
This SDK provides consistent interfaces for interacting with foundational services such as object storage, destination management, audit logging, data anonymization, data privacy integration, telemetry, and secure credential handling.

The Python SDK offers a clean, type-safe API following Python best practices while maintaining compatibility with the SAP Application Foundation ecosystem.

Expand All @@ -24,6 +24,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi
- **Secret Resolver**
- **Telemetry & Observability**
- **Data Anonymization Service**
- **Data Privacy Integration Service**
- **Print Service**

## Requirements and Setup
Expand Down Expand Up @@ -80,6 +81,7 @@ Each module has comprehensive usage guides:
- [Telemetry](src/sap_cloud_sdk/core/telemetry/user-guide.md)
- [Print](src/sap_cloud_sdk/print/user-guide.md)
- [Data Anonymization](src/sap_cloud_sdk/core/data_anonymization/user-guide.md)
- [Data Privacy Integration](src/sap_cloud_sdk/core/dpi_ng/consent/user-guide.md)

## Support, Feedback, Contributing

Expand Down
37 changes: 37 additions & 0 deletions docs/INTEGRATION_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,42 @@ CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-c

**Note**: The test fixture automatically onboards test repositories (standard and version-enabled) at session start and cleans them up on teardown. No pre-existing repositories are required.

### DPI NG Consent Integration Tests

For DPI NG Consent integration tests, configure the following variables in `.env_integration_tests`:

```bash
# DPI NG Consent Configuration - use the shared base URL (applies to all DPI NG sub-services)
CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL=https://your-dpi-ng-service-host
```

Authentication is configurable via one of three methods:

**Option A - Static Bearer Token**

```bash
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_BEARER_TOKEN=your-bearer-token-here
```

**Option B - OAuth 2.0 Client Credentials**

```bash
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TOKEN_URL=https://your-auth-host/oauth/token
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_ID=your-client-id
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CLIENT_SECRET=your-client-secret
```

**Option C - Mutual TLS (mTLS)**

```bash
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CERT_FILE=/path/to/client.crt
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_KEY_FILE=/path/to/client.key
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_CA_FILE=/path/to/ca.crt # optional
CLOUD_SDK_CFG_DPI_NG_CONSENT_DEFAULT_TENANT_ID=your-tenant-id-here
```

Tests are skipped automatically when `CLOUD_SDK_CFG_DPI_NG_DEFAULT_BASE_URL` or all auth variables are missing.

### ObjectStore Integration Tests

For ObjectStore integration tests, configure the following variables in `.env_integration_tests`:
Expand Down Expand Up @@ -168,6 +204,7 @@ uv run pytest tests/core/integration/auditlog -v
uv run pytest tests/core/integration/data_anonymization -v
uv run pytest tests/destination/integration/ -v
uv run pytest tests/dms/integration/ -v
uv run pytest tests/core/integration/dpi_ng/consent/ -v
uv run pytest tests/objectstore/integration/ -v
```

Expand Down
4 changes: 3 additions & 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.37.0"
version = "0.38.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -27,6 +27,7 @@ dependencies = [
"opentelemetry-api>=1.42.1",
"opentelemetry-sdk>=1.42.1",
"mcp>=1.1.0",
"python-odata>=0.7.0",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -61,6 +62,7 @@ dev = [
"langchain-community>=0.3.0",
"litellm>=1.40.0",
"langchain-litellm>=0.6.6",
"responses>=0.25",
]

[tool.pytest.ini_options]
Expand Down
49 changes: 49 additions & 0 deletions src/sap_cloud_sdk/core/dpi_ng/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""DPI Next Gen SDK modules.

Shared building blocks for all DPI NG capabilities:

- :mod:`sap_cloud_sdk.core.dpi_ng.auth` — AuthProvider ABC + strategies
- :mod:`sap_cloud_sdk.core.dpi_ng.config` — BaseCapabilityConfig
- :mod:`sap_cloud_sdk.core.dpi_ng.exceptions` — DPINGError hierarchy
- :mod:`sap_cloud_sdk.core.dpi_ng.odata_client` — BaseODataClient
"""

from .auth import (
AuthProvider,
BearerTokenAuth,
ClientCertificateAuth,
ClientCredentialsAuth,
)
from .config import BaseCapabilityConfig
from .exceptions import (
AuthenticationError,
AuthorizationError,
ClientCreationError,
ConflictError,
DPINGError,
NotFoundError,
ODataError,
ValidationError,
)
from .odata_client import BaseODataClient

__all__ = [
# auth
"AuthProvider",
"BearerTokenAuth",
"ClientCredentialsAuth",
"ClientCertificateAuth",
# config
"BaseCapabilityConfig",
# exceptions
"DPINGError",
"ClientCreationError",
"AuthenticationError",
"AuthorizationError",
"ValidationError",
"NotFoundError",
"ConflictError",
"ODataError",
# odata transport
"BaseODataClient",
]
213 changes: 213 additions & 0 deletions src/sap_cloud_sdk/core/dpi_ng/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""Authentication strategy implementations shared across all DPI NG capabilities.

Each provider implements AuthProvider.apply(), which configures the
requests.Session passed to it to inject the chosen auth mechanism.

Supported strategies:
- BearerTokenAuth - static bearer token (e.g. already-fetched XSUAA token)
- ClientCredentialsAuth - OAuth2 client_credentials flow with automatic token refresh
- ClientCertificateAuth - mTLS with client certificate + private key
"""

from __future__ import annotations

import logging
import time
from abc import ABC, abstractmethod

import requests
import requests.auth

logger = logging.getLogger(__name__)


class AuthProvider(ABC):
"""Abstract base for all authentication strategies.

Subclasses configure a ``requests.Session`` to inject their chosen
mechanism (headers, cert, custom auth flow, etc.).
Adding a new auth type means subclassing this; nothing else changes.
"""

@abstractmethod
def apply(self, session: requests.Session) -> None:
"""Configure the requests.Session to inject authentication."""


class BearerTokenAuth(AuthProvider):
"""Static bearer token - use when the caller manages token lifecycle externally."""

def __init__(self, token: str) -> None:
"""Store the bearer token.

Args:
token: A valid bearer token string (e.g. an already-fetched XSUAA access token).

Raises:
ValueError: If *token* is an empty string.
"""
logger.info("Invoked BearerTokenAuth.__init__")
if not token:
logger.error("token is empty")
raise ValueError("token must not be empty")
self._token = token
logger.info("Exiting BearerTokenAuth.__init__")

def apply(self, session: requests.Session) -> None:
"""Set the ``Authorization: Bearer <token>`` header on the session.

Args:
session: The ``requests.Session`` to configure.
"""
logger.info("Invoked BearerTokenAuth.apply")
session.headers["Authorization"] = f"Bearer {self._token}"
logger.info("Exiting BearerTokenAuth.apply")


class ClientCredentialsAuth(AuthProvider):
"""OAuth2 client_credentials flow with automatic token refresh.

Fetches a bearer token from ``token_url`` using ``client_id`` / ``client_secret``
and refreshes it transparently 60 seconds before it expires.
"""

def __init__(self, token_url: str, client_id: str, client_secret: str) -> None:
"""Store OAuth2 credentials for lazy token fetching.

Args:
token_url: Full URL of the OAuth2 token endpoint
(e.g. ``https://<subdomain>.authentication.eu10.hana.ondemand.com/oauth/token``).
client_id: OAuth2 client identifier.
client_secret: OAuth2 client secret.

Raises:
ValueError: If any of *token_url*, *client_id*, or *client_secret* is empty.
"""
logger.info("Invoked ClientCredentialsAuth.__init__")
if not token_url or not client_id or not client_secret:
logger.error("token_url, client_id, or client_secret is empty")
raise ValueError("token_url, client_id, and client_secret are all required")
self._token_url = token_url
self._client_id = client_id
self._client_secret = client_secret
logger.info("Exiting ClientCredentialsAuth.__init__")

def apply(self, session: requests.Session) -> None:
"""Attach the OAuth2 flow handler to the session.

Tokens are fetched on the first request and refreshed automatically
60 seconds before expiry.

Args:
session: The ``requests.Session`` to configure.
"""
logger.info("Invoked ClientCredentialsAuth.apply")
session.auth = _OAuth2Flow(
self._token_url, self._client_id, self._client_secret
)
logger.info("Exiting ClientCredentialsAuth.apply")


class ClientCertificateAuth(AuthProvider):
"""Mutual TLS (mTLS) authentication using a client certificate and private key.

Args:
cert_file: Path to the client certificate PEM file.
key_file: Path to the client private key PEM file.
ca_file: Path to the CA certificate PEM file for server verification.
When omitted, the system CA bundle is used.
"""

def __init__(
self,
cert_file: str,
key_file: str,
ca_file: str | None = None,
) -> None:
"""Store mTLS file paths.

Args:
cert_file: Path to the PEM-encoded client certificate file.
key_file: Path to the PEM-encoded client private key file.
ca_file: Path to a PEM-encoded CA certificate file for server
verification. When omitted the system CA bundle is used.

Raises:
ValueError: If *cert_file* or *key_file* is empty.
"""
logger.info("Invoked ClientCertificateAuth.__init__")
if not cert_file or not key_file:
logger.error("cert_file or key_file is empty")
raise ValueError("cert_file and key_file are required")
self._cert_file = cert_file
self._key_file = key_file
self._ca_file = ca_file
logger.info("Exiting ClientCertificateAuth.__init__")

def apply(self, session: requests.Session) -> None:
"""Configure the session with the client cert/key pair and optional CA bundle.

Args:
session: The ``requests.Session`` to configure.
"""
logger.info("Invoked ClientCertificateAuth.apply")
session.cert = (self._cert_file, self._key_file)
if self._ca_file:
session.verify = self._ca_file # ty: ignore[invalid-assignment]
logger.debug("Custom CA bundle applied — ca_file=%s", self._ca_file)
logger.info("Exiting ClientCertificateAuth.apply")


# ------------------------------------------------------------------
# Internal OAuth2 flow - not part of the public API
# ------------------------------------------------------------------


class _OAuth2Flow(requests.auth.AuthBase):
"""requests.auth.AuthBase implementation that handles token fetch and refresh."""

# 60-second buffer before actual expiry to avoid clock-skew races
_EXPIRY_BUFFER = 60.0

def __init__(self, token_url: str, client_id: str, client_secret: str) -> None:
"""Initialise with OAuth2 endpoint and credentials; token is fetched lazily."""
self._token_url = token_url
self._client_id = client_id
self._client_secret = client_secret
self._access_token: str | None = None
self._expires_at: float = 0.0

def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
"""Inject a valid bearer token, refreshing it if expired or not yet fetched."""
if self._is_expired():
logger.debug("Token expired or absent — fetching new token")
self._fetch_token()
r.headers["Authorization"] = f"Bearer {self._access_token}" # ty: ignore[invalid-assignment]
return r

def _is_expired(self) -> bool:
"""Return True if no token has been fetched or the expiry buffer has been reached."""
return self._access_token is None or time.monotonic() >= self._expires_at

def _fetch_token(self) -> None:
"""POST to the token URL and store the new access token and its expiry time."""
logger.info("Invoked _OAuth2Flow._fetch_token")
try:
resp = requests.post(
self._token_url,
data={
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
},
)
resp.raise_for_status()
payload = resp.json()
self._access_token = payload["access_token"]
expires_in: float = float(payload.get("expires_in", 3600))
self._expires_at = time.monotonic() + expires_in - self._EXPIRY_BUFFER
logger.debug("Token acquired — expires_in=%.0fs", expires_in)
except Exception:
logger.exception("Failed to fetch access token from %s", self._token_url)
raise
logger.info("Exiting _OAuth2Flow._fetch_token")
Loading
Loading