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
84 changes: 84 additions & 0 deletions docs/GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,87 @@
- Never log or expose sensitive information (passwords, tokens, etc.)
- Validate all inputs, especially from external sources
- Follow principle of least privilege in code design

## Credential Binding Rotation

BTP service bindings can rotate at runtime. Long-lived clients holding stale credentials will fail with auth errors once the old secrets expire. Every SDK module that reads credentials from mounts or env vars must support rotation.

### Two resilience layers

**Proactive** — check `ConfigFactory.has_changed()` before every credential use. If the mtime of the secret directory changed, re-read the binding and discard any cached tokens or session objects.

**Reactive** — on a credential-rejection error (e.g. HTTP 401 for OAuth modules, S3 `InvalidAccessKeyId`/`SignatureDoesNotMatch` for object store), refresh credentials and retry the operation once.

### Pattern for OAuth2 modules

Token providers and auth classes must accept either a fixed config object **or** a callable (factory) returning the config:

```python
if callable(config) and not isinstance(config, MyConfig):
self._config_factory: Callable[[], MyConfig] = config
self._config = config()
else:
self._config_factory = lambda: config # static; no rotation tracking
self._config = config
```

Before serving a cached token, call `_refresh_if_rotated()`:

```python
def _refresh_if_rotated(self) -> None:
has_changed = getattr(self._config_factory, "has_changed", None)
if callable(has_changed) and has_changed():
self._config = self._config_factory()
self._cached_token = None # discard stale token
# rebuild session/client if needed
```

### Pattern for key-based clients (e.g. object store / MinIO)

Credentials are baked into the client at construction time, so the client itself must be rebuilt on rotation. Wrap every public operation in `_execute_with_retry`:

```python
_CREDENTIAL_ERROR_CODES = frozenset({"InvalidAccessKeyId", "SignatureDoesNotMatch"})

def _execute_with_retry(self, fn):
self._refresh_if_rotated()
try:
return fn()
except S3Error as e:
if e.code in _CREDENTIAL_ERROR_CODES:
with self._lock:
self._creds = self._config_factory()
self._client = self._create_client()
return fn()
raise
```

Use a `threading.Lock` when rebuilding the client to avoid races under concurrent calls.

### Public API factory functions (`create_client`)

- **Auto-detection path** (no explicit `config=`): pass a `ConfigFactory` instance directly to the token provider / auth class. `ConfigFactory` carries `has_changed()` automatically.
- **Explicit `config=` path**: wrap in a static lambda to preserve the factory interface without rotation tracking.

```python
def create_client(*, instance=None, config=None):
if config is not None:
credentials = lambda: config # static, no rotation
else:
credentials = _make_config_factory(instance)
return MyClient(credentials)
```

### `ConfigFactory` contract

`ConfigFactory[C]` (from `sap_cloud_sdk.core.secret_resolver`) reads bindings on every `__call__()` and tracks secret directory mtime via `has_changed()`. To use it, the binding dataclass must:
- Have all fields defaulting to `""` so `binding_cls()` can be called with no args.
- Implement `validate()` raising on missing required fields.

### Testing rotation

Every module that supports rotation must have tests for:
1. **Proactive**: `has_changed()` returns `True` → token/session/client is rebuilt before the next operation.
2. **Reactive** (key-based clients): credential-rejection error on first call → retried once with fresh credentials.
3. **No rebuild**: `has_changed()` returns `False` → existing session/client reused.
4. **Static config**: plain config object (no `has_changed`) → no error, no rotation check.
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.53.0"
version = "0.54.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
33 changes: 26 additions & 7 deletions src/sap_cloud_sdk/adms/_ias_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from __future__ import annotations

from typing import Optional
from typing import Callable, Optional

import requests

Expand Down Expand Up @@ -76,17 +76,35 @@ class IasTokenFetcher:

def __init__(
self,
config: AdmsConfig,
config: AdmsConfig | Callable[[], AdmsConfig],
session: Optional[requests.Session] = None,
cache: Optional[TokenCache] = None,
) -> None:
self._ias_url = config.ias_url.rstrip("/")
self._client_id = config.client_id
self._client_secret = config.client_secret
if callable(config) and not isinstance(config, AdmsConfig):
self._config_factory: Callable[[], AdmsConfig] = config
self._config = config()
else:
self._config_factory = lambda: config # type: ignore[arg-type]
self._config = config # type: ignore[assignment]
self._session = session or requests.Session()
self._token_url = self._ias_url + "/oauth2/token"
self._cache: TokenCache = cache or InMemoryTokenCache()
self._resource: Optional[str] = config.resource
self._apply_config()

def _apply_config(self) -> None:
"""Sync derived attributes from the current ``_config``."""
self._ias_url = self._config.ias_url.rstrip("/")
self._client_id = self._config.client_id
self._client_secret = self._config.client_secret
self._token_url = self._ias_url + "/oauth2/token"
self._resource: Optional[str] = self._config.resource

def _refresh_if_rotated(self) -> None:
"""Proactively refresh credentials and clear the token cache if the binding changed."""
has_changed = getattr(self._config_factory, "has_changed", None)
if callable(has_changed) and has_changed():
self._config = self._config_factory()
self._apply_config()
self._cache = InMemoryTokenCache()

# ------------------------------------------------------------------
# Public API
Expand All @@ -105,6 +123,7 @@ def get_token(self) -> str:
AuthError: If the IAS token endpoint returns an error or the
response is missing ``access_token``.
"""
self._refresh_if_rotated()
cached = self._cache.get(_CC_CACHE_KEY)
if cached:
return cached
Expand Down
31 changes: 24 additions & 7 deletions src/sap_cloud_sdk/adms/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
_DocumentRelationApi,
)
from sap_cloud_sdk.adms._token_cache import TokenCache
from sap_cloud_sdk.adms.config import AdmsConfig, load_from_env_or_mount
from sap_cloud_sdk.adms.config import AdmsConfig, _make_config_factory
from sap_cloud_sdk.adms.exceptions import ConfigError


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -171,9 +172,18 @@ def create_client(
raise ValueError(
"instance must not be an empty string; omit it to use 'default'"
)
binding = config or load_from_env_or_mount(instance)
token_fetcher = IasTokenFetcher(config=binding, cache=token_cache)
http = AdmsHttp(config=binding, token_fetcher=token_fetcher, user_jwt=user_jwt)
try:
if config is not None:
token_fetcher = IasTokenFetcher(config=config, cache=token_cache)
else:
token_fetcher = IasTokenFetcher(
config=_make_config_factory(instance), cache=token_cache
)
except RuntimeError as exc:
raise ConfigError(str(exc)) from exc
http = AdmsHttp(
config=token_fetcher._config, token_fetcher=token_fetcher, user_jwt=user_jwt
)
return AdmsClient(http)


Expand Down Expand Up @@ -206,10 +216,17 @@ def create_async_client(
raise ValueError(
"instance must not be an empty string; omit it to use 'default'"
)
binding = config or load_from_env_or_mount(instance)
token_fetcher = IasTokenFetcher(config=binding, cache=token_cache)
try:
if config is not None:
token_fetcher = IasTokenFetcher(config=config, cache=token_cache)
else:
token_fetcher = IasTokenFetcher(
config=_make_config_factory(instance), cache=token_cache
)
except RuntimeError as exc:
raise ConfigError(str(exc)) from exc
http = AsyncAdmsHttp(
config=binding,
config=token_fetcher._config,
token_fetcher=token_fetcher,
client=http_client,
user_jwt=user_jwt,
Expand Down
36 changes: 36 additions & 0 deletions src/sap_cloud_sdk/adms/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@
"""

from dataclasses import dataclass
from typing import TYPE_CHECKING

from sap_cloud_sdk.core.secret_resolver.resolver import (
read_from_mount_and_fallback_to_env_var,
)
from sap_cloud_sdk.adms.exceptions import ConfigError

if TYPE_CHECKING:
from sap_cloud_sdk.core.secret_resolver import ConfigFactory

_DEFAULT_INSTANCE = "default"
_SECRET_MOUNT_BASE = "/etc/secrets/appfnd"
_ENV_VAR_BASE = "CLOUD_SDK_CFG"
Expand Down Expand Up @@ -125,3 +129,35 @@ def load_from_env_or_mount(instance: str | None = None) -> AdmsConfig:

raw.validate()
return raw.to_config()


def _make_config_factory(instance: str | None = None) -> "ConfigFactory[AdmsConfig]":
"""Return a :class:`~sap_cloud_sdk.core.secret_resolver.ConfigFactory` for the given instance.

The factory re-reads the binding on every call and tracks the secret
directory mtime for proactive rotation detection.

Args:
instance: Binding instance name. Defaults to ``"default"``.

Returns:
A callable that produces a fresh :class:`AdmsConfig`.
"""
from sap_cloud_sdk.core.secret_resolver import ConfigFactory

inst = instance or _DEFAULT_INSTANCE

def _extract(binding: _BindingData) -> AdmsConfig:
try:
return binding.to_config()
except Exception as exc:
raise ConfigError(
f"failed to load ADMS configuration for instance '{inst}': {exc}"
) from exc

return ConfigFactory(
module="adms",
instance=inst,
binding_cls=_BindingData,
extract=_extract,
)
6 changes: 3 additions & 3 deletions src/sap_cloud_sdk/aicore/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ before the cached OAuth token expires — so agents never see a 401 at all.
```python
from sap_cloud_sdk.aicore import set_aicore_config, watch_aicore_config

set_aicore_config() # load credentials at startup
set_aicore_config() # load credentials at startup
watch_aicore_config() # proactive reload on secret rotation
```

Expand All @@ -158,7 +158,7 @@ directly, bypassing the SDK's reactive handler. Two options:
from sap_cloud_sdk.aicore import set_aicore_config, watch_aicore_config

set_aicore_config()
watch_aicore_config() # ADD THIS — no other changes needed
watch_aicore_config() # ADD THIS — no other changes needed
```

**Option B — also add reactive reload for ChatLiteLLM:**
Expand All @@ -171,7 +171,7 @@ from sap_cloud_sdk.aicore import (
)

set_aicore_config()
patch_litellm_for_credential_rotation() # patches litellm.completion globally
patch_litellm_for_credential_rotation() # patches litellm.completion globally
watch_aicore_config()
```

Expand Down
4 changes: 2 additions & 2 deletions src/sap_cloud_sdk/core/auditlog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
ChangeAttribute,
DeletedAttribute,
)
from sap_cloud_sdk.core.auditlog.config import AuditLogConfig, _load_config_from_env
from sap_cloud_sdk.core.auditlog.config import AuditLogConfig, _make_config_factory
from sap_cloud_sdk.core.auditlog.exceptions import (
AuditLogError,
ClientCreationError,
Expand Down Expand Up @@ -67,7 +67,7 @@ def create_client(
transport = HttpTransport(config)
return AuditLogClient(transport, _telemetry_source=_telemetry_source)

transport = HttpTransport(_load_config_from_env())
transport = HttpTransport(_make_config_factory())
return AuditLogClient(transport, _telemetry_source=_telemetry_source)

except Exception as e:
Expand Down
62 changes: 44 additions & 18 deletions src/sap_cloud_sdk/core/auditlog/_http_transport.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""HTTP transport implementation for cloud mode."""

from typing import Callable, Optional

import requests
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
Expand All @@ -18,29 +20,52 @@


class HttpTransport(Transport):
"""HTTP-based transport for cloud mode with OAuth2 authentication."""
"""HTTP-based transport for cloud mode with OAuth2 authentication.

Accepts either a fixed :class:`AuditLogConfig` or a config factory (any callable
returning ``AuditLogConfig`` with an optional ``has_changed() -> bool`` method).
When a factory is supplied, credentials are re-read on every token refresh and
the factory's ``has_changed()`` method is checked proactively before each request
so that rotated secrets are picked up automatically.
"""

def __init__(self, config: AuditLogConfig):
def __init__(self, config: AuditLogConfig | Callable[[], AuditLogConfig]):
"""Initialize HTTP transport with provided configuration.

Args:
config: AuditLogConfig with OAuth2 credentials and service URL
config: AuditLogConfig (or a factory returning one) with OAuth2 credentials
and service URL.
"""
self.config = config

token_url = f"{config.oauth_url.rstrip('/')}/oauth/token"

client = BackendApplicationClient(client_id=config.client_id)
self.oauth = OAuth2Session(client=client)
if callable(config) and not isinstance(config, AuditLogConfig):
self._config_factory: Callable[[], AuditLogConfig] = config
self.config = config()
else:
self._config_factory = lambda: config # type: ignore[arg-type]
self.config = config # type: ignore[assignment]
self.oauth: Optional[OAuth2Session] = None

def _ensure_session(self) -> OAuth2Session:
"""Return a valid OAuth2 session, refreshing credentials if the binding changed."""
has_changed = getattr(self._config_factory, "has_changed", None)
if callable(has_changed) and has_changed():
self.config = self._config_factory()
self.oauth = None

if self.oauth is None:
token_url = f"{self.config.oauth_url.rstrip('/')}/oauth/token"
client = BackendApplicationClient(client_id=self.config.client_id)
oauth = OAuth2Session(client=client)
try:
oauth.fetch_token(
token_url=token_url,
client_id=self.config.client_id,
client_secret=self.config.client_secret,
)
except Exception as e:
raise AuthenticationError(f"Failed to obtain OAuth2 token: {e}")
self.oauth = oauth

try:
_token = self.oauth.fetch_token(
token_url=token_url,
client_id=config.client_id,
client_secret=config.client_secret,
)
except Exception as e:
raise AuthenticationError(f"Failed to obtain OAuth2 token: {e}")
return self.oauth

def send(self, event: AuditMessage) -> None:
"""Send audit event via HTTP.
Expand All @@ -58,7 +83,8 @@ def send(self, event: AuditMessage) -> None:
path_prefix = "/audit-log/oauth2/v2"
url = f"{self.config.service_url.rstrip('/')}{path_prefix}{endpoint}"

response = self.oauth.post(
oauth = self._ensure_session()
response = oauth.post(
url,
json=event_dict,
headers={"Content-Type": "application/json"},
Expand Down
Loading
Loading