diff --git a/pyproject.toml b/pyproject.toml index d4835fa4..b7e99254 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "mcp>=1.1.0,<3", "python-odata>=0.7.0", "cryptography>=48.0.1,<51", + "cachetools~=5.5.2", ] [project.optional-dependencies] diff --git a/src/sap_cloud_sdk/cache/__init__.py b/src/sap_cloud_sdk/cache/__init__.py new file mode 100644 index 00000000..2ce5234e --- /dev/null +++ b/src/sap_cloud_sdk/cache/__init__.py @@ -0,0 +1,53 @@ +"""SAP Cloud SDK for Python - Cache module. + +Provides a domain-agnostic, pluggable cache layer shared across all SDK +modules. Supports tenant and tenant-user isolation, configurable TTL and +expiry buffers, LRU eviction, and custom backends for multi-instance +deployments. + +Global configuration example:: + + from sap_cloud_sdk.cache import CacheConfig, configure_cache + + configure_cache(CacheConfig( + default_ttl_seconds=600, + expiry_buffer_seconds=60, + max_size=2000, + )) + +Disabling the cache for a specific client:: + + from sap_cloud_sdk.destination import create_client + from sap_cloud_sdk.cache import CacheConfig + + client = create_client(cache_config=CacheConfig(enabled=False)) + +Custom backend example (Redis):: + + from sap_cloud_sdk.cache import CacheBackend, CacheConfig, configure_cache + + class RedisCacheBackend(CacheBackend): + def get(self, key): ... + def set(self, key, value, ttl_seconds): ... + def delete(self, key): ... + def clear(self): ... + + configure_cache(CacheConfig(backend=RedisCacheBackend(...))) +""" + +from sap_cloud_sdk.cache._backend import CacheBackend +from sap_cloud_sdk.cache._config import CacheConfig, configure_cache, get_cache_config +from sap_cloud_sdk.cache._isolation import IsolationStrategy +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend +from sap_cloud_sdk.cache.exceptions import BackendError, CacheError + +__all__ = [ + "CacheBackend", + "CacheConfig", + "configure_cache", + "get_cache_config", + "IsolationStrategy", + "InMemoryLRUBackend", + "CacheError", + "BackendError", +] diff --git a/src/sap_cloud_sdk/cache/_backend.py b/src/sap_cloud_sdk/cache/_backend.py new file mode 100644 index 00000000..50b9e9e9 --- /dev/null +++ b/src/sap_cloud_sdk/cache/_backend.py @@ -0,0 +1,35 @@ +"""Abstract cache backend interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class CacheBackend(ABC): + """Domain-agnostic key/value cache backend. + + The backend has no awareness of tenants, TTL policy, namespaces, or domain + types. All of that is handled by the :class:`~sap_cloud_sdk.cache._cache.Cache` + façade before keys and values reach the backend. + + Implement this to plug in any shared cache (Redis, Memcached, etc.) for + multi-instance deployments (Kyma ``replicas > 1``, Cloud Foundry + ``instances > 1``). + """ + + @abstractmethod + def get(self, key: str) -> Any | None: + """Return the value for *key*, or ``None`` if absent or expired.""" + + @abstractmethod + def set(self, key: str, value: Any, ttl_seconds: int) -> None: + """Store *value* under *key* with a time-to-live in seconds.""" + + @abstractmethod + def delete(self, key: str) -> None: + """Remove the entry for *key* (no-op if absent).""" + + @abstractmethod + def clear(self) -> None: + """Remove all entries.""" diff --git a/src/sap_cloud_sdk/cache/_cache.py b/src/sap_cloud_sdk/cache/_cache.py new file mode 100644 index 00000000..2821d183 --- /dev/null +++ b/src/sap_cloud_sdk/cache/_cache.py @@ -0,0 +1,148 @@ +"""SDK-internal cache façade. + +SDK modules instantiate :class:`Cache` per-client and call its +``get``/``set``/``evict``/``reset`` methods. The façade handles: + +- Selecting the active backend (per-client override or global default). +- Building namespaced, isolation-scoped keys. +- Applying the expiry buffer before forwarding TTLs to the backend. +- Honouring the ``enabled`` flag. + +This class is **not part of the public API** and is not exported from +``sap_cloud_sdk.cache``. Import it directly:: + + from sap_cloud_sdk.cache._cache import Cache +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sap_cloud_sdk.cache._backend import CacheBackend +from sap_cloud_sdk.cache._config import CacheConfig, get_cache_config +from sap_cloud_sdk.cache._isolation import build_isolation_key +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend +from sap_cloud_sdk.cache.exceptions import BackendError + +logger = logging.getLogger(__name__) + + +class Cache: + """Per-client cache façade. + + Tenant and user isolation keys are derived automatically from the active + :func:`~sap_cloud_sdk.core.runtime_context.get_context` — callers do not + need to pass identity information explicitly. + + Args: + config: Per-client override. When ``None``, the global config set via + :func:`~sap_cloud_sdk.cache._config.configure_cache` is used. + The config is snapshotted at construction time — subsequent calls + to :func:`configure_cache` do not affect an existing ``Cache``. + """ + + def __init__(self, config: CacheConfig | None = None) -> None: + self._config: CacheConfig = config if config is not None else get_cache_config() + self._backend = self._resolve_backend() + + # ------------------------------------------------------------------ + # Public façade methods + # ------------------------------------------------------------------ + + def get(self, namespace: str, key: str) -> Any | None: + """Return a cached value, or ``None`` on miss or when disabled. + + Args: + namespace: Domain namespace, e.g. ``"csrf"``. + key: Domain-specific key, e.g. the service root URL. + """ + if not self._config.enabled: + return None + + full_key = self._make_full_key(namespace, key) + try: + return self._backend.get(full_key) + except Exception as e: + logger.warning("cache backend get() raised an exception: %s", e) + return None + + def set( + self, + namespace: str, + key: str, + value: Any, + ttl_seconds: int | None, + ) -> None: + """Store a value in the cache. + + The effective TTL forwarded to the backend is: + - *ttl_seconds* − ``expiry_buffer_seconds`` when *ttl_seconds* is given. + - ``default_ttl_seconds`` − ``expiry_buffer_seconds`` as fallback. + + The result is clamped to a minimum of 1 second. + + Args: + namespace: Domain namespace. + key: Domain-specific key. + value: Value to cache (must be serialisable by the backend). + ttl_seconds: Natural TTL derived from the resource (e.g. token + ``exp`` minus now). Pass ``None`` to use the configured + default. + """ + if not self._config.enabled: + return + + raw_ttl = ( + ttl_seconds if ttl_seconds is not None else self._config.default_ttl_seconds + ) + effective_ttl = max(raw_ttl - self._config.expiry_buffer_seconds, 1) + + full_key = self._make_full_key(namespace, key) + try: + self._backend.set(full_key, value, effective_ttl) + except Exception as e: + raise BackendError(f"cache backend set() failed: {e}") from e + + def evict(self, namespace: str, key: str) -> None: + """Remove a single entry (no-op if absent or cache is disabled). + + Args: + namespace: Domain namespace. + key: Domain-specific key. + """ + if not self._config.enabled: + return + + full_key = self._make_full_key(namespace, key) + try: + self._backend.delete(full_key) + except Exception as e: + logger.warning("cache backend delete() raised an exception: %s", e) + + def reset(self) -> None: + """Clear all entries from the backend. + + Use with care in production — this forces a full re-fetch of every + cached resource on the next access. + """ + try: + self._backend.clear() + except Exception as e: + logger.warning("cache backend clear() raised an exception: %s", e) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _resolve_backend(self) -> CacheBackend: + if self._config.backend is not None: + return self._config.backend + return InMemoryLRUBackend( + max_size=self._config.max_size, + on_evict=self._config.on_evict, + ) + + def _make_full_key(self, namespace: str, key: str) -> str: + isolation_key = build_isolation_key(strategy=self._config.isolation_strategy) + return f"{namespace}::{isolation_key}::{key}" diff --git a/src/sap_cloud_sdk/cache/_config.py b/src/sap_cloud_sdk/cache/_config.py new file mode 100644 index 00000000..5fb6713e --- /dev/null +++ b/src/sap_cloud_sdk/cache/_config.py @@ -0,0 +1,67 @@ +"""Global cache configuration and registry.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from sap_cloud_sdk.cache._backend import CacheBackend + from sap_cloud_sdk.cache._isolation import IsolationStrategy + + +@dataclass +class CacheConfig: + """Configuration for the SDK cache layer. + + Can be set globally via :func:`configure_cache` or passed per-client + to override the global for that client only. + + Attributes: + enabled: Master on/off switch. When ``False``, all gets return + ``None`` and all sets are no-ops. + isolation_strategy: Override the automatic isolation selection. + ``None`` means auto-detect from context (``TENANT_USER`` when a + user ID is present, ``TENANT`` otherwise). + default_ttl_seconds: Fallback TTL used when the caller does not + supply a natural TTL (e.g. from a token ``exp`` claim). + expiry_buffer_seconds: Seconds subtracted from any derived TTL to + pre-invalidate entries before they expire on the remote service. + max_size: Maximum number of entries in the built-in in-memory + backend before LRU eviction kicks in. + backend: Custom cache backend. ``None`` uses the built-in + :class:`~sap_cloud_sdk.cache._lru_backend.InMemoryLRUBackend`. + on_evict: Optional callback invoked when an entry is evicted. + Signature: ``(key: str, reason: str) -> None`` where *reason* + is one of ``"ttl"``, ``"lru"``, or ``"manual"``. + """ + + enabled: bool = True + isolation_strategy: IsolationStrategy | None = None + default_ttl_seconds: int = 300 + expiry_buffer_seconds: int = 30 + max_size: int = 1000 + backend: CacheBackend | None = None + on_evict: Callable[[str, str], None] | None = field(default=None, repr=False) + + +_global_config: CacheConfig = CacheConfig() + + +def configure_cache(config: CacheConfig) -> None: + """Set the global cache configuration. + + Must be called before any SDK client is created. Hot-reload is not + supported — changes after clients are constructed have no effect on + already-instantiated :class:`~sap_cloud_sdk.cache._cache.Cache` objects. + + Args: + config: The new global configuration. + """ + global _global_config + _global_config = config + + +def get_cache_config() -> CacheConfig: + """Return the current global cache configuration.""" + return _global_config diff --git a/src/sap_cloud_sdk/cache/_isolation.py b/src/sap_cloud_sdk/cache/_isolation.py new file mode 100644 index 00000000..def754be --- /dev/null +++ b/src/sap_cloud_sdk/cache/_isolation.py @@ -0,0 +1,78 @@ +"""Cache isolation strategy and key construction.""" + +from __future__ import annotations + +import hashlib +import logging +from enum import Enum + +from sap_cloud_sdk.core.runtime_context import APP_TENANT_ID, USER_ID, get_context + +logger = logging.getLogger(__name__) + + +class IsolationStrategy(str, Enum): + """Controls how cache keys are scoped to tenants and users. + + ``TENANT`` + One namespace per tenant. Suitable for data that is shared across all + users of a tenant (e.g. configuration, service metadata). + + ``TENANT_USER`` + One namespace per (tenant, user) pair. Prevents cross-user cache hits + within the same tenant. Use for user-specific data. + + ``PROVIDER`` + A single shared namespace for the whole service, ignoring tenant and + user context entirely. Use for service-wide data such as CSRF tokens + or provider-level metadata that is not tenant-specific. + """ + + TENANT = "tenant" + TENANT_USER = "tenant_user" + PROVIDER = "provider" + + +def build_isolation_key(strategy: IsolationStrategy | None = None) -> str: + """Derive a cache isolation key from the current runtime context. + + Reads ``tenant_id`` and ``user_id`` implicitly from the active + :func:`~sap_cloud_sdk.core.runtime_context.get_context`. + + When *strategy* is ``None``, the strategy is selected automatically: + ``TENANT_USER`` if a user ID is present in context, ``TENANT`` otherwise. + + Downgrading explicitly from ``TENANT_USER`` to ``TENANT`` when a user ID + is available risks cross-user contamination and triggers a warning. + + Args: + strategy: Explicit override. ``None`` means auto-detect. + + Returns: + An opaque string suitable for inclusion in a cache key. + """ + if strategy is IsolationStrategy.PROVIDER: + return "__provider__" + + ctx = get_context() + tenant_id = ctx.get(APP_TENANT_ID) + user_id = ctx.get(USER_ID) + + has_user = bool(user_id) + effective = strategy + + if effective is None: + effective = ( + IsolationStrategy.TENANT_USER if has_user else IsolationStrategy.TENANT + ) + elif effective is IsolationStrategy.TENANT and has_user: + logger.warning( + "cache isolation downgraded from TENANT_USER to TENANT while user_id is " + "present — this may cause cross-user cache contamination" + ) + + if effective is IsolationStrategy.TENANT_USER and has_user: + material = f"{tenant_id}|{user_id}" + return hashlib.sha256(material.encode()).hexdigest()[:32] + + return tenant_id or "" diff --git a/src/sap_cloud_sdk/cache/_lru_backend.py b/src/sap_cloud_sdk/cache/_lru_backend.py new file mode 100644 index 00000000..aeeec918 --- /dev/null +++ b/src/sap_cloud_sdk/cache/_lru_backend.py @@ -0,0 +1,108 @@ +"""In-memory LRU + TTL cache backend backed by cachetools.""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Any, Callable + +from cachetools import TTLCache + +from sap_cloud_sdk.cache._backend import CacheBackend + +logger = logging.getLogger(__name__) + + +class _EvictingTTLCache(TTLCache): + """TTLCache subclass that fires an optional callback on eviction.""" + + def __init__( + self, + maxsize: int, + ttl: float, + on_evict: Callable[[str, str], None] | None, + ) -> None: + super().__init__(maxsize=maxsize, ttl=ttl) + self._on_evict = on_evict + + def popitem(self) -> tuple[Any, Any]: + key, value = super().popitem() + if self._on_evict is not None: + try: + self._on_evict(str(key), "lru") + except Exception: + logger.debug("on_evict callback raised an exception", exc_info=True) + return key, value + + +class InMemoryLRUBackend(CacheBackend): + """Thread-safe in-memory LRU + TTL cache. + + Uses :class:`cachetools.TTLCache` under the hood. Each entry has an + individual TTL supplied at write time. Least-recently-used entries are + evicted when *max_size* is exceeded. + + Suitable for single-process (single-instance) deployments. For + horizontally scaled deployments implement a custom + :class:`~sap_cloud_sdk.cache._backend.CacheBackend` backed by a shared + store (Redis, Memcached, etc.) and pass it via + :class:`~sap_cloud_sdk.cache._config.CacheConfig`. + + Args: + max_size: Maximum number of entries before LRU eviction. + on_evict: Optional callback ``(key, reason) -> None``. *reason* is + ``"lru"`` for capacity evictions or ``"manual"`` for explicit + :meth:`delete` / :meth:`clear` calls. TTL expiry is handled + transparently by cachetools and does not fire this callback. + """ + + def __init__( + self, + max_size: int = 1000, + on_evict: Callable[[str, str], None] | None = None, + ) -> None: + # cachetools TTLCache requires a single TTL at construction time; we + # work around this by storing (value, expires_at_monotonic) tuples and + # setting a very large cache-level TTL so cachetools never expires + # entries on its own. Expiry is enforced in get() by comparing + # time.monotonic() against the stored deadline. + self._cache: _EvictingTTLCache = _EvictingTTLCache( + maxsize=max_size, + ttl=86400 * 365, # 1 year — expiry managed manually per-entry + on_evict=on_evict, + ) + self._on_evict = on_evict + self._lock = threading.Lock() + + def get(self, key: str) -> Any | None: + with self._lock: + entry = self._cache.get(key) + if entry is None: + return None + value, expires_at = entry + if time.monotonic() >= expires_at: + try: + del self._cache[key] + except KeyError: + pass + return None + return value + + def set(self, key: str, value: Any, ttl_seconds: int) -> None: + expires_at = time.monotonic() + max(ttl_seconds, 1) + with self._lock: + self._cache[key] = (value, expires_at) + + def delete(self, key: str) -> None: + with self._lock: + existed = self._cache.pop(key, None) is not None + if existed and self._on_evict is not None: + try: + self._on_evict(key, "manual") + except Exception: + logger.debug("on_evict callback raised an exception", exc_info=True) + + def clear(self) -> None: + with self._lock: + self._cache.clear() diff --git a/src/sap_cloud_sdk/cache/exceptions.py b/src/sap_cloud_sdk/cache/exceptions.py new file mode 100644 index 00000000..b0f360fa --- /dev/null +++ b/src/sap_cloud_sdk/cache/exceptions.py @@ -0,0 +1,9 @@ +"""Exception classes for the cache module.""" + + +class CacheError(Exception): + """Base exception for all cache module errors.""" + + +class BackendError(CacheError): + """Raised when a custom cache backend raises an unexpected exception.""" diff --git a/src/sap_cloud_sdk/cache/py.typed b/src/sap_cloud_sdk/cache/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/sap_cloud_sdk/cache/user-guide.md b/src/sap_cloud_sdk/cache/user-guide.md new file mode 100644 index 00000000..de745707 --- /dev/null +++ b/src/sap_cloud_sdk/cache/user-guide.md @@ -0,0 +1,170 @@ +# Cache: User Guide + +Provides a domain-agnostic, pluggable cache layer shared across all SAP Cloud SDK modules. Supports tenant, tenant-user, and provider isolation, configurable TTL and expiry buffers, LRU eviction, and custom backends for multi-instance deployments. + +## Installation + +```bash +uv add sap-cloud-sdk +``` + +No BTP service binding is required — the cache module is self-contained. + +## Quick Start + +By default every SDK client uses an in-process LRU cache with sensible defaults. No configuration is needed unless you want to tune it. + +```python +from sap_cloud_sdk.cache import CacheConfig, configure_cache + +configure_cache( + CacheConfig( + default_ttl_seconds=600, + expiry_buffer_seconds=60, + max_size=2000, + ) +) +``` + +Call `configure_cache()` **once at startup**, before any SDK client is created. Changes made after client construction have no effect on already-instantiated clients. + +## Configuration Reference + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | `bool` | `True` | Master on/off switch. `False` turns all gets into misses and all sets into no-ops. | +| `isolation_strategy` | `IsolationStrategy \| None` | `None` | Override automatic isolation. `None` = auto-detect from context. | +| `default_ttl_seconds` | `int` | `300` | Fallback TTL when no natural TTL is available. | +| `expiry_buffer_seconds` | `int` | `30` | Subtracted from any TTL before storing, to pre-invalidate entries. | +| `max_size` | `int` | `1000` | Maximum entries in the built-in backend before LRU eviction. | +| `backend` | `CacheBackend \| None` | `None` | Custom backend. `None` uses the built-in `InMemoryLRUBackend`. | +| `on_evict` | `Callable[[str, str], None] \| None` | `None` | Callback fired on eviction. Arguments: `(key, reason)` where reason is `"lru"` or `"manual"`. | + +## Isolation Strategy + +The cache automatically scopes keys to the current tenant (and optionally user) to prevent cross-tenant and cross-user cache hits. Tenant and user identity are read implicitly from the active [`RuntimeContext`](../core/runtime_context) — callers do not pass them explicitly. + +| Strategy | Scope | When to use | +|---|---|---| +| `TENANT` | Per tenant | Data shared across all users of a tenant (configuration, metadata). Auto-selected when no user ID is present. | +| `TENANT_USER` | Per (tenant, user) pair | User-specific data. Auto-selected when a user ID is present in context. | +| `PROVIDER` | Service-wide (single shared namespace) | Data that is not tenant-specific, e.g. CSRF tokens or provider-level metadata. Ignores tenant and user context entirely. | + +Override the strategy globally: + +```python +from sap_cloud_sdk.cache import CacheConfig, IsolationStrategy, configure_cache + +configure_cache(CacheConfig(isolation_strategy=IsolationStrategy.TENANT)) +``` + +Downgrading from `TENANT_USER` to `TENANT` when a user ID is present logs a warning, as it risks cross-user contamination. + +## Disabling the Cache Per Client + +Pass a `CacheConfig(enabled=False)` when constructing a client to disable caching for that client only, without affecting the global configuration: + +```python +from sap_cloud_sdk.destination import create_client +from sap_cloud_sdk.cache import CacheConfig + +client = create_client(cache_config=CacheConfig(enabled=False)) +``` + +## Eviction Callback + +```python +import logging +from sap_cloud_sdk.cache import CacheConfig, configure_cache + +logger = logging.getLogger(__name__) + + +def on_evict(key: str, reason: str) -> None: + logger.info("cache evicted key=%s reason=%s", key, reason) + + +configure_cache(CacheConfig(on_evict=on_evict)) +``` + +`reason` values: +- `"lru"` — evicted because `max_size` was exceeded +- `"manual"` — removed by an explicit `evict()` or `reset()` call + +## Custom Backend (Multi-Instance Deployments) + +The built-in `InMemoryLRUBackend` is process-local. For horizontally scaled deployments implement `CacheBackend` and pass it via `CacheConfig`: + +```python +from typing import Any + +from sap_cloud_sdk.cache import CacheBackend, CacheConfig, configure_cache + + +class MyCacheBackend(CacheBackend): + def get(self, key: str) -> Any | None: ... + def set(self, key: str, value: Any, ttl_seconds: int) -> None: ... + def delete(self, key: str) -> None: ... + def clear(self) -> None: ... + + +configure_cache(CacheConfig(backend=MyCacheBackend())) +``` + +The backend interface has exactly four methods and no awareness of tenants, TTL policy, or domain types — all of that is handled by the SDK before keys reach your backend. + +> **Redis and other distributed backends:** A built-in Redis backend is not yet available. The `CacheBackend` ABC is designed to support distributed backends, and first-party support is planned for a future release. Until then, implement `CacheBackend` against your store of choice as shown above. + +## Integration Example + +For a real, in-tree example of how the cache is used, see the CSRF token handling in the core HTTP client (`sap_cloud_sdk.core.protocol.http.client.HttpClient`, opt-in via `csrf_enabled=True`). It uses `IsolationStrategy.PROVIDER` to cache a service-wide CSRF token, injects it on mutating requests, and evicts-and-refetches on a `403`. + +## API Reference + +### `configure_cache(config: CacheConfig) -> None` + +Sets the global cache configuration. Must be called before any SDK client is created. + +### `get_cache_config() -> CacheConfig` + +Returns the current global cache configuration. + +### `class CacheConfig` + +Dataclass holding all cache settings. See [Configuration Reference](#configuration-reference) above. + +### `class CacheBackend` (ABC) + +Abstract base for custom backends. Implement `get`, `set`, `delete`, and `clear`. + +### `class InMemoryLRUBackend` + +The default backend. Thread-safe, LRU + TTL eviction, backed by `cachetools.TTLCache`. Suitable for single-process deployments. + +### `class IsolationStrategy` + +Enum with values `TENANT`, `TENANT_USER`, and `PROVIDER`. See [Isolation Strategy](#isolation-strategy) above. + +## Multi-tenancy + +- **Supported:** Yes, `TENANT`, `TENANT_USER`, and `PROVIDER` isolation strategies +- **Authentication:** N/A, the cache module does not perform BTP authentication +- **How to use:** Set `isolation_strategy` in `CacheConfig`. Tenant and user identity are read implicitly from the active [`RuntimeContext`](../core/runtime_context); auto-selection uses `TENANT_USER` when a user ID is present in context, otherwise `TENANT`. Use `PROVIDER` for service-wide data that must not be tenant-scoped. +- **Further reading:** N/A + +## Error Handling + +```python +from sap_cloud_sdk.cache.exceptions import BackendError, CacheError + +try: + # SDK client operations that use the cache internally + ... +except BackendError as e: + # Raised when a custom backend raises during a set() call + print(f"Cache backend error: {e}") +except CacheError as e: + print(f"Cache error: {e}") +``` + +Cache misses and backend errors during `get()` are always safe — the SDK treats them as misses and falls back to a fresh fetch. diff --git a/src/sap_cloud_sdk/core/protocol/http/client.py b/src/sap_cloud_sdk/core/protocol/http/client.py index c7a684aa..cb1f37f3 100644 --- a/src/sap_cloud_sdk/core/protocol/http/client.py +++ b/src/sap_cloud_sdk/core/protocol/http/client.py @@ -6,12 +6,26 @@ import requests +from sap_cloud_sdk.cache._cache import Cache +from sap_cloud_sdk.cache._config import CacheConfig +from sap_cloud_sdk.cache._isolation import IsolationStrategy from sap_cloud_sdk.core.protocol.http.models import ( AuthProvider, HttpMethod, _DEFAULT_TIMEOUT, ) +# CSRF handshake constants. OData/CAP services issue a token on a safe probe +# request carrying ``X-CSRF-Token: Fetch`` and require it echoed back on every +# state-changing request. +_CSRF_HEADER = "X-CSRF-Token" +_CSRF_FETCH_VALUE = "Fetch" +_CSRF_NAMESPACE = "csrf" +_CSRF_DEFAULT_TTL_SECONDS = 1800 + +# Methods that mutate server state and therefore require a CSRF token. +_CSRF_PROTECTED_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + class HttpClient: """Concrete HTTP client with injectable auth and single-retry on 401. @@ -23,11 +37,20 @@ class HttpClient: :meth:`AuthProvider.invalidate` and retries the request exactly once. This recovers from credentials that were revoked after secret rotation. + When *csrf_enabled* is ``True``, state-changing requests (POST, PUT, PATCH, + DELETE) carry an ``X-CSRF-Token`` fetched from the service root and cached + via :class:`~sap_cloud_sdk.cache._cache.Cache`. CSRF tokens are + service-wide (not tenant-scoped), so the cache uses + :attr:`~sap_cloud_sdk.cache.IsolationStrategy.PROVIDER`. On a 403 response + the cached token is evicted, re-fetched, and the request retried once. + Args: base_url: Base URL for all requests (trailing slash is stripped). auth_provider: Authentication provider. Pass ``None`` for unauthenticated (plain :class:`requests.Session`) mode. timeout: Timeout in seconds for resource-server requests. + csrf_enabled: Enable OData/CAP CSRF token fetch-and-carry for mutating + requests. Defaults to ``False`` so existing callers are unaffected. """ def __init__( @@ -36,6 +59,7 @@ def __init__( auth_provider: Optional[AuthProvider] = None, *, timeout: float = _DEFAULT_TIMEOUT, + csrf_enabled: bool = False, ) -> None: self._base_url = base_url.rstrip("/") self._auth_provider = auth_provider @@ -43,6 +67,17 @@ def __init__( self._plain_session: Optional[requests.Session] = ( requests.Session() if auth_provider is None else None ) + self._csrf_enabled = csrf_enabled + self._csrf_cache: Optional[Cache] = ( + Cache( + CacheConfig( + isolation_strategy=IsolationStrategy.PROVIDER, + default_ttl_seconds=_CSRF_DEFAULT_TTL_SECONDS, + ) + ) + if csrf_enabled + else None + ) def request( self, @@ -52,7 +87,7 @@ def request( tenant_subdomain: Optional[str] = None, **kwargs: Any, ) -> requests.Response: - """Execute a request, retrying once on 401. + """Execute a request, retrying once on 401 (and once on 403 for CSRF). Args: method: HTTP verb (``"GET"``, ``"POST"``, etc.). @@ -64,22 +99,112 @@ def request( Returns: Raw :class:`requests.Response`. Callers must check the status code. """ - response = self._execute(method, path, tenant_subdomain, **kwargs) + method_str = ( + method.value if isinstance(method, HttpMethod) else str(method).upper() + ) + + needs_csrf = ( + self._csrf_cache is not None and method_str in _CSRF_PROTECTED_METHODS + ) + if needs_csrf: + return self._request_with_csrf(method_str, path, tenant_subdomain, **kwargs) + + response = self._execute(method_str, path, tenant_subdomain, **kwargs) if response.status_code == 401 and self._auth_provider is not None: self._auth_provider.invalidate(tenant_subdomain) - response = self._execute(method, path, tenant_subdomain, **kwargs) + response = self._execute(method_str, path, tenant_subdomain, **kwargs) return response - def _execute( + def _request_with_csrf( self, - method: HttpMethod | str, + method_str: str, path: str, tenant_subdomain: Optional[str], **kwargs: Any, ) -> requests.Response: - method_str = ( - method.value if isinstance(method, HttpMethod) else str(method).upper() + """Execute a mutating request carrying a CSRF token, retrying on 403. + + On a 403 the cached token is evicted, re-fetched, and the request is + retried exactly once — CSRF tokens have server-side TTLs and may have + expired since they were cached. + """ + token = self._get_csrf_token(tenant_subdomain) + response = self._execute_with_header( + method_str, path, tenant_subdomain, token, **kwargs ) + if response.status_code == 403: + assert self._csrf_cache is not None + self._csrf_cache.evict(_CSRF_NAMESPACE, self._csrf_key()) + token = self._get_csrf_token(tenant_subdomain) + response = self._execute_with_header( + method_str, path, tenant_subdomain, token, **kwargs + ) + return response + + def _execute_with_header( + self, + method_str: str, + path: str, + tenant_subdomain: Optional[str], + csrf_token: Optional[str], + **kwargs: Any, + ) -> requests.Response: + headers = {**kwargs.pop("headers", {})} + if csrf_token: + headers[_CSRF_HEADER] = csrf_token + return self._execute( + method_str, path, tenant_subdomain, headers=headers, **kwargs + ) + + def _get_csrf_token(self, tenant_subdomain: Optional[str]) -> Optional[str]: + """Return the cached CSRF token for the service root, fetching on miss.""" + assert self._csrf_cache is not None + key = self._csrf_key() + cached = self._csrf_cache.get(_CSRF_NAMESPACE, key) + if cached is not None: + return cached + + token = self._fetch_csrf_token(tenant_subdomain) + if token: + self._csrf_cache.set(_CSRF_NAMESPACE, key, token, ttl_seconds=None) + return token + + def _fetch_csrf_token(self, tenant_subdomain: Optional[str]) -> Optional[str]: + """Probe the service root to obtain a fresh CSRF token. + + Uses a GET to the base URL carrying ``X-CSRF-Token: Fetch``. The + response status is not error-checked — many OData services return a + non-2xx on the bare root yet still echo back a valid token header. + """ + if self._auth_provider is not None: + session = self._auth_provider.get_session(tenant_subdomain) + base_url = getattr(self._auth_provider, "base_url", None) or self._base_url + else: + assert self._plain_session is not None + session = self._plain_session + base_url = self._base_url + + resp = session.request( + "GET", + f"{base_url}/", + headers={_CSRF_HEADER: _CSRF_FETCH_VALUE}, + timeout=self._timeout, + ) + return resp.headers.get(_CSRF_HEADER) + + def _csrf_key(self) -> str: + """Cache key for the CSRF token — the current effective service root.""" + if self._auth_provider is not None: + return getattr(self._auth_provider, "base_url", None) or self._base_url + return self._base_url + + def _execute( + self, + method_str: str, + path: str, + tenant_subdomain: Optional[str], + **kwargs: Any, + ) -> requests.Response: if self._auth_provider is not None: session: requests.Session = self._auth_provider.get_session( tenant_subdomain diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index 2c64727d..17dcfa8e 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -13,6 +13,7 @@ class Module(str, Enum): AUDITLOG = "auditlog" AUDITLOG_NG = "auditlog_ng" BOOTSTRAP = "bootstrap" + CACHE = "cache" CBC = "cbc" DATA_ANONYMIZATION = "data_anonymization" DESTINATION = "destination" diff --git a/tests/cache/__init__.py b/tests/cache/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cache/unit/__init__.py b/tests/cache/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cache/unit/test_backend.py b/tests/cache/unit/test_backend.py new file mode 100644 index 00000000..ed62b4e2 --- /dev/null +++ b/tests/cache/unit/test_backend.py @@ -0,0 +1,102 @@ +"""Unit tests for InMemoryLRUBackend.""" + +import time + +import pytest + +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend + + +class TestInMemoryLRUBackendGet: + def test_get_missing_key_returns_none(self) -> None: + backend = InMemoryLRUBackend() + assert backend.get("nonexistent") is None + + def test_get_returns_stored_value(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "v", ttl_seconds=60) + assert backend.get("k") == "v" + + def test_get_after_ttl_expiry_returns_none(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "v", ttl_seconds=1) + time.sleep(1.1) + assert backend.get("k") is None + + def test_get_stores_arbitrary_value_types(self) -> None: + backend = InMemoryLRUBackend() + payload = {"a": [1, 2, 3], "b": True} + backend.set("k", payload, ttl_seconds=60) + assert backend.get("k") == payload + + +class TestInMemoryLRUBackendSet: + def test_set_overwrites_existing_key(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "first", ttl_seconds=60) + backend.set("k", "second", ttl_seconds=60) + assert backend.get("k") == "second" + + def test_set_with_zero_ttl_clamps_to_one_second(self) -> None: + backend = InMemoryLRUBackend() + # TTL of 0 is clamped to 1 inside Cache.set(); here we test the backend + # directly with ttl_seconds=1 to confirm it stores then expires. + backend.set("k", "v", ttl_seconds=1) + assert backend.get("k") == "v" + + +class TestInMemoryLRUBackendDelete: + def test_delete_removes_existing_entry(self) -> None: + backend = InMemoryLRUBackend() + backend.set("k", "v", ttl_seconds=60) + backend.delete("k") + assert backend.get("k") is None + + def test_delete_nonexistent_key_is_noop(self) -> None: + backend = InMemoryLRUBackend() + backend.delete("missing") # must not raise + + def test_delete_fires_on_evict_callback_with_manual_reason(self) -> None: + evictions: list[tuple[str, str]] = [] + backend = InMemoryLRUBackend(on_evict=lambda k, r: evictions.append((k, r))) + backend.set("k", "v", ttl_seconds=60) + backend.delete("k") + assert evictions == [("k", "manual")] + + +class TestInMemoryLRUBackendClear: + def test_clear_removes_all_entries(self) -> None: + backend = InMemoryLRUBackend() + backend.set("a", 1, ttl_seconds=60) + backend.set("b", 2, ttl_seconds=60) + backend.clear() + assert backend.get("a") is None + assert backend.get("b") is None + + def test_clear_on_empty_backend_is_noop(self) -> None: + backend = InMemoryLRUBackend() + backend.clear() # must not raise + + +class TestInMemoryLRUBackendLRUEviction: + def test_lru_eviction_when_max_size_exceeded(self) -> None: + backend = InMemoryLRUBackend(max_size=2) + backend.set("a", 1, ttl_seconds=60) + backend.set("b", 2, ttl_seconds=60) + # access "a" to make "b" the LRU + backend.get("a") + # adding "c" should evict "b" (LRU) + backend.set("c", 3, ttl_seconds=60) + assert backend.get("a") is not None + assert backend.get("c") is not None + assert backend.get("b") is None + + def test_lru_eviction_fires_on_evict_callback(self) -> None: + evictions: list[tuple[str, str]] = [] + backend = InMemoryLRUBackend( + max_size=1, + on_evict=lambda k, r: evictions.append((k, r)), + ) + backend.set("first", "v", ttl_seconds=60) + backend.set("second", "v", ttl_seconds=60) + assert any(reason == "lru" for _, reason in evictions) diff --git a/tests/cache/unit/test_cache.py b/tests/cache/unit/test_cache.py new file mode 100644 index 00000000..241bf0a8 --- /dev/null +++ b/tests/cache/unit/test_cache.py @@ -0,0 +1,197 @@ +"""Unit tests for the Cache façade.""" + +from unittest.mock import MagicMock + +import pytest + +from sap_cloud_sdk.cache._backend import CacheBackend +from sap_cloud_sdk.cache._cache import Cache +from sap_cloud_sdk.cache._config import CacheConfig, configure_cache +from sap_cloud_sdk.cache._isolation import IsolationStrategy +from sap_cloud_sdk.core.runtime_context import APP_TENANT_ID, USER_ID, RuntimeContext +from sap_cloud_sdk.core.runtime_context._context import sdk_context +from sap_cloud_sdk.cache.exceptions import BackendError + + +def _ctx(tenant_id: str | None = None, user_id: str | None = None) -> RuntimeContext: + values: dict = {} + if tenant_id is not None: + values[APP_TENANT_ID] = tenant_id + if user_id is not None: + values[USER_ID] = user_id + return RuntimeContext(values) + + +class TestCacheGet: + def test_get_miss_returns_none(self) -> None: + cache = Cache(CacheConfig()) + with sdk_context(_ctx(tenant_id="t1")): + assert cache.get("ns", "key") is None + + def test_get_returns_stored_value(self) -> None: + cache = Cache(CacheConfig()) + with sdk_context(_ctx(tenant_id="t1")): + cache.set("ns", "key", "value", ttl_seconds=60) + assert cache.get("ns", "key") == "value" + + def test_get_disabled_always_returns_none(self) -> None: + cache = Cache(CacheConfig(enabled=False)) + with sdk_context(_ctx(tenant_id="t1")): + cache.set("ns", "key", "value", ttl_seconds=60) + assert cache.get("ns", "key") is None + + def test_get_different_tenants_are_isolated(self) -> None: + cache = Cache(CacheConfig()) + with sdk_context(_ctx(tenant_id="t1")): + cache.set("ns", "key", "t1-value", ttl_seconds=60) + with sdk_context(_ctx(tenant_id="t2")): + assert cache.get("ns", "key") is None + + def test_get_different_users_are_isolated(self) -> None: + cache = Cache(CacheConfig()) + with sdk_context(_ctx(tenant_id="t", user_id="u1")): + cache.set("ns", "key", "u1-value", ttl_seconds=60) + with sdk_context(_ctx(tenant_id="t", user_id="u2")): + assert cache.get("ns", "key") is None + + def test_get_swallows_backend_exceptions_and_returns_none(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.get.side_effect = RuntimeError("backend down") + cache = Cache(CacheConfig(backend=bad_backend)) + with sdk_context(_ctx(tenant_id="t")): + assert cache.get("ns", "key") is None + + +class TestCacheSet: + def test_set_disabled_is_noop(self) -> None: + backend = MagicMock(spec=CacheBackend) + cache = Cache(CacheConfig(enabled=False, backend=backend)) + with sdk_context(_ctx(tenant_id="t")): + cache.set("ns", "key", "v", ttl_seconds=60) + backend.set.assert_not_called() + + def test_set_applies_expiry_buffer(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache(CacheConfig(expiry_buffer_seconds=10, backend=backend)) + with sdk_context(_ctx(tenant_id="t")): + cache.set("ns", "key", "v", ttl_seconds=60) + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 50 # 60 - 10 + + def test_set_uses_default_ttl_when_none_given(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache( + CacheConfig( + default_ttl_seconds=300, expiry_buffer_seconds=30, backend=backend + ) + ) + with sdk_context(_ctx(tenant_id="t")): + cache.set("ns", "key", "v", ttl_seconds=None) + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 270 # 300 - 30 + + def test_set_clamps_negative_effective_ttl_to_one(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache(CacheConfig(expiry_buffer_seconds=100, backend=backend)) + with sdk_context(_ctx(tenant_id="t")): + cache.set("ns", "key", "v", ttl_seconds=50) + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 1 + + def test_set_raises_backend_error_on_backend_exception(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.set.side_effect = IOError("redis unavailable") + cache = Cache(CacheConfig(backend=bad_backend)) + with sdk_context(_ctx(tenant_id="t")): + with pytest.raises(BackendError): + cache.set("ns", "key", "v", ttl_seconds=60) + + +class TestCacheEvict: + def test_evict_removes_entry(self) -> None: + cache = Cache(CacheConfig()) + with sdk_context(_ctx(tenant_id="t")): + cache.set("ns", "key", "v", ttl_seconds=60) + cache.evict("ns", "key") + assert cache.get("ns", "key") is None + + def test_evict_disabled_is_noop(self) -> None: + backend = MagicMock(spec=CacheBackend) + cache = Cache(CacheConfig(enabled=False, backend=backend)) + with sdk_context(_ctx(tenant_id="t")): + cache.evict("ns", "key") + backend.delete.assert_not_called() + + def test_evict_swallows_backend_exceptions(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.delete.side_effect = RuntimeError("backend down") + cache = Cache(CacheConfig(backend=bad_backend)) + with sdk_context(_ctx(tenant_id="t")): + cache.evict("ns", "key") # must not raise + + +class TestCacheReset: + def test_reset_clears_all_entries(self) -> None: + cache = Cache(CacheConfig()) + with sdk_context(_ctx(tenant_id="t")): + cache.set("ns", "a", 1, ttl_seconds=60) + cache.set("ns", "b", 2, ttl_seconds=60) + cache.reset() + assert cache.get("ns", "a") is None + assert cache.get("ns", "b") is None + + def test_reset_swallows_backend_exceptions(self) -> None: + bad_backend = MagicMock(spec=CacheBackend) + bad_backend.clear.side_effect = RuntimeError("backend down") + cache = Cache(CacheConfig(backend=bad_backend)) + cache.reset() # must not raise + + +class TestCacheProviderIsolation: + def test_provider_strategy_shares_across_tenants(self) -> None: + cache = Cache(CacheConfig(isolation_strategy=IsolationStrategy.PROVIDER)) + with sdk_context(_ctx(tenant_id="t1")): + cache.set("csrf", "svc", "token-1", ttl_seconds=60) + with sdk_context(_ctx(tenant_id="t2")): + assert cache.get("csrf", "svc") == "token-1" + + +class TestCachePerClientConfigOverride: + def setup_method(self) -> None: + configure_cache(CacheConfig(default_ttl_seconds=300)) + + def test_per_client_config_overrides_global(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + per_client = CacheConfig( + default_ttl_seconds=60, expiry_buffer_seconds=0, backend=backend + ) + cache = Cache(per_client) + with sdk_context(_ctx(tenant_id="t")): + cache.set("ns", "key", "v", ttl_seconds=None) + _, _, forwarded_ttl = backend.set.call_args[0] + assert forwarded_ttl == 60 # uses per-client default, not global 300 + + def teardown_method(self) -> None: + configure_cache(CacheConfig()) + + +class TestCacheFullKeyStructure: + def test_full_key_includes_namespace_and_key(self) -> None: + backend = MagicMock(spec=CacheBackend) + backend.get.return_value = None + cache = Cache( + CacheConfig( + isolation_strategy=IsolationStrategy.TENANT, + backend=backend, + ) + ) + with sdk_context(_ctx(tenant_id="tenant-xyz")): + cache.set("destination", "my-dest", "v", ttl_seconds=60) + full_key = backend.set.call_args[0][0] + assert full_key.startswith("destination::") + assert full_key.endswith("::my-dest") + assert "tenant-xyz" in full_key diff --git a/tests/cache/unit/test_config.py b/tests/cache/unit/test_config.py new file mode 100644 index 00000000..ada788c5 --- /dev/null +++ b/tests/cache/unit/test_config.py @@ -0,0 +1,59 @@ +"""Unit tests for CacheConfig, configure_cache, and get_cache_config.""" + +import pytest + +from sap_cloud_sdk.cache._config import CacheConfig, configure_cache, get_cache_config +from sap_cloud_sdk.cache._lru_backend import InMemoryLRUBackend + + +class TestGetCacheConfigDefaults: + def test_get_cache_config_returns_cacheconfig_instance(self) -> None: + cfg = get_cache_config() + assert isinstance(cfg, CacheConfig) + + def test_default_config_is_enabled(self) -> None: + cfg = CacheConfig() + assert cfg.enabled is True + + def test_default_ttl_seconds(self) -> None: + assert CacheConfig().default_ttl_seconds == 300 + + def test_default_expiry_buffer_seconds(self) -> None: + assert CacheConfig().expiry_buffer_seconds == 30 + + def test_default_max_size(self) -> None: + assert CacheConfig().max_size == 1000 + + def test_default_backend_is_none(self) -> None: + assert CacheConfig().backend is None + + def test_default_isolation_strategy_is_none(self) -> None: + assert CacheConfig().isolation_strategy is None + + +class TestConfigureCache: + def setup_method(self) -> None: + # Reset global config to defaults before each test. + configure_cache(CacheConfig()) + + def test_configure_cache_replaces_global_config(self) -> None: + new_cfg = CacheConfig(default_ttl_seconds=999) + configure_cache(new_cfg) + assert get_cache_config().default_ttl_seconds == 999 + + def test_configure_cache_with_custom_backend(self) -> None: + backend = InMemoryLRUBackend(max_size=50) + configure_cache(CacheConfig(backend=backend)) + assert get_cache_config().backend is backend + + def test_configure_cache_disabled(self) -> None: + configure_cache(CacheConfig(enabled=False)) + assert get_cache_config().enabled is False + + def test_configure_cache_multiple_times_uses_last(self) -> None: + configure_cache(CacheConfig(default_ttl_seconds=100)) + configure_cache(CacheConfig(default_ttl_seconds=200)) + assert get_cache_config().default_ttl_seconds == 200 + + def teardown_method(self) -> None: + configure_cache(CacheConfig()) diff --git a/tests/cache/unit/test_isolation.py b/tests/cache/unit/test_isolation.py new file mode 100644 index 00000000..58e79853 --- /dev/null +++ b/tests/cache/unit/test_isolation.py @@ -0,0 +1,111 @@ +"""Unit tests for IsolationStrategy and build_isolation_key.""" + +import hashlib +import logging + +import pytest + +from sap_cloud_sdk.cache._isolation import IsolationStrategy, build_isolation_key +from sap_cloud_sdk.core.runtime_context import APP_TENANT_ID, USER_ID, RuntimeContext +from sap_cloud_sdk.core.runtime_context._context import sdk_context + + +def _ctx(tenant_id: str | None = None, user_id: str | None = None) -> RuntimeContext: + values: dict = {} + if tenant_id is not None: + values[APP_TENANT_ID] = tenant_id + if user_id is not None: + values[USER_ID] = user_id + return RuntimeContext(values) + + +class TestBuildIsolationKeyAutoSelect: + def test_no_user_id_returns_tenant_id(self) -> None: + with sdk_context(_ctx(tenant_id="tenant-abc")): + assert build_isolation_key() == "tenant-abc" + + def test_empty_user_id_returns_tenant_id(self) -> None: + with sdk_context(_ctx(tenant_id="tenant-abc", user_id="")): + assert build_isolation_key() == "tenant-abc" + + def test_with_user_id_returns_sha256_hash(self) -> None: + with sdk_context(_ctx(tenant_id="tenant-abc", user_id="user-123")): + expected = hashlib.sha256(b"tenant-abc|user-123").hexdigest()[:32] + assert build_isolation_key() == expected + + def test_hash_is_32_chars(self) -> None: + with sdk_context(_ctx(tenant_id="t", user_id="u")): + assert len(build_isolation_key()) == 32 + + def test_different_users_produce_different_keys(self) -> None: + with sdk_context(_ctx(tenant_id="t", user_id="user-1")): + k1 = build_isolation_key() + with sdk_context(_ctx(tenant_id="t", user_id="user-2")): + k2 = build_isolation_key() + assert k1 != k2 + + def test_different_tenants_produce_different_keys(self) -> None: + with sdk_context(_ctx(tenant_id="tenant-1", user_id="u")): + k1 = build_isolation_key() + with sdk_context(_ctx(tenant_id="tenant-2", user_id="u")): + k2 = build_isolation_key() + assert k1 != k2 + + def test_same_inputs_produce_stable_hash(self) -> None: + with sdk_context(_ctx(tenant_id="t", user_id="u")): + k1 = build_isolation_key() + k2 = build_isolation_key() + assert k1 == k2 + + +class TestBuildIsolationKeyExplicitStrategy: + def test_explicit_tenant_strategy_ignores_user_id(self) -> None: + with sdk_context(_ctx(tenant_id="tenant-abc", user_id="user-123")): + key = build_isolation_key(strategy=IsolationStrategy.TENANT) + assert key == "tenant-abc" + + def test_explicit_tenant_user_strategy_with_user_id(self) -> None: + with sdk_context(_ctx(tenant_id="t", user_id="u")): + key = build_isolation_key(strategy=IsolationStrategy.TENANT_USER) + expected = hashlib.sha256(b"t|u").hexdigest()[:32] + assert key == expected + + def test_explicit_tenant_user_strategy_without_user_id_falls_back_to_tenant( + self, + ) -> None: + with sdk_context(_ctx(tenant_id="tenant-abc")): + key = build_isolation_key(strategy=IsolationStrategy.TENANT_USER) + assert key == "tenant-abc" + + def test_downgrade_warning_is_logged( + self, caplog: pytest.LogCaptureFixture + ) -> None: + with caplog.at_level(logging.WARNING, logger="sap_cloud_sdk.cache._isolation"): + with sdk_context(_ctx(tenant_id="t", user_id="u")): + build_isolation_key(strategy=IsolationStrategy.TENANT) + assert any("cross-user" in record.message for record in caplog.records) + + +class TestBuildIsolationKeyProvider: + def test_provider_strategy_returns_fixed_constant(self) -> None: + with sdk_context(_ctx(tenant_id="tenant-abc", user_id="user-123")): + key = build_isolation_key(strategy=IsolationStrategy.PROVIDER) + assert key == "__provider__" + + def test_provider_strategy_ignores_empty_context(self) -> None: + with sdk_context(RuntimeContext()): + key = build_isolation_key(strategy=IsolationStrategy.PROVIDER) + assert key == "__provider__" + + def test_provider_is_stable_across_tenants(self) -> None: + with sdk_context(_ctx(tenant_id="tenant-1")): + k1 = build_isolation_key(strategy=IsolationStrategy.PROVIDER) + with sdk_context(_ctx(tenant_id="tenant-2")): + k2 = build_isolation_key(strategy=IsolationStrategy.PROVIDER) + assert k1 == k2 == "__provider__" + + +class TestBuildIsolationKeyNoContext: + def test_empty_context_returns_empty_string(self) -> None: + with sdk_context(RuntimeContext()): + assert build_isolation_key() == "" diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index 77cb965a..3bfd3dd2 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -58,7 +58,7 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 17 + assert len(all_modules) == 18 assert Module.ADMS in all_modules assert Module.CBC in all_modules assert Module.AGENT_MEMORY in all_modules @@ -67,6 +67,7 @@ def test_all_modules_present(self): assert Module.AUDITLOG in all_modules assert Module.AUDITLOG_NG in all_modules assert Module.BOOTSTRAP in all_modules + assert Module.CACHE in all_modules assert Module.DATA_ANONYMIZATION in all_modules assert Module.DESTINATION in all_modules assert Module.DMS in all_modules diff --git a/tests/core/unit/test_http_client.py b/tests/core/unit/test_http_client.py index 83d4c997..b81f1c30 100644 --- a/tests/core/unit/test_http_client.py +++ b/tests/core/unit/test_http_client.py @@ -8,9 +8,10 @@ from sap_cloud_sdk.core.protocol.http import HttpClient, HttpMethod, XsuaaAuthProvider -def _make_response(status_code: int) -> MagicMock: +def _make_response(status_code: int, headers: dict | None = None) -> MagicMock: resp = MagicMock(spec=requests.Response) resp.status_code = status_code + resp.headers = headers or {} return resp @@ -219,6 +220,99 @@ def test_close_no_auth_closes_plain_session(self): assert client._plain_session is None +class TestHttpClientCsrf: + """CSRF token fetch-and-carry via the cache module (csrf_enabled=True).""" + + def _csrf_client( + self, base_url: str = "https://svc.example.com" + ) -> tuple[HttpClient, MagicMock, MagicMock]: + auth = _make_mock_auth() + session = MagicMock() + auth.get_session.return_value = session + client = HttpClient(base_url, auth, csrf_enabled=True) + return client, auth, session + + @staticmethod + def _csrf_fetch_response(token: str) -> MagicMock: + return _make_response(200, headers={"X-CSRF-Token": token}) + + def test_disabled_by_default_no_fetch(self) -> None: + client, _, session = _make_client_with_mock_session() + client.request(HttpMethod.POST, "/items", json={"a": 1}) + # Exactly one call — the POST itself, no CSRF probe. + assert session.request.call_count == 1 + assert session.request.call_args[0][0] == "POST" + + def test_cold_miss_fetches_and_injects_token(self) -> None: + client, _, session = self._csrf_client() + session.request.side_effect = [ + self._csrf_fetch_response("tok-abc"), # CSRF probe (GET root) + _make_response(201), # the POST + ] + resp = client.request(HttpMethod.POST, "/items", json={"a": 1}) + + assert resp.status_code == 201 + # First call is the GET probe carrying Fetch. + probe = session.request.call_args_list[0] + assert probe[0][0] == "GET" + assert probe[1]["headers"]["X-CSRF-Token"] == "Fetch" + # Second call is the POST carrying the fetched token. + post = session.request.call_args_list[1] + assert post[0][0] == "POST" + assert post[1]["headers"]["X-CSRF-Token"] == "tok-abc" + + def test_cache_hit_skips_second_fetch(self) -> None: + client, _, session = self._csrf_client() + session.request.side_effect = [ + self._csrf_fetch_response("tok-abc"), # probe for first POST + _make_response(201), # first POST + _make_response(201), # second POST (no probe) + ] + client.request(HttpMethod.POST, "/items", json={"a": 1}) + client.request(HttpMethod.POST, "/items", json={"b": 2}) + + methods = [c[0][0] for c in session.request.call_args_list] + # Only one GET probe total; the token is reused on the second POST. + assert methods == ["GET", "POST", "POST"] + second_post = session.request.call_args_list[2] + assert second_post[1]["headers"]["X-CSRF-Token"] == "tok-abc" + + def test_403_evicts_refetches_and_retries(self) -> None: + client, _, session = self._csrf_client() + session.request.side_effect = [ + self._csrf_fetch_response("stale"), # initial probe + _make_response(403), # POST rejected — token stale + self._csrf_fetch_response("fresh"), # re-probe after eviction + _make_response(201), # retried POST succeeds + ] + resp = client.request(HttpMethod.POST, "/items", json={"a": 1}) + + assert resp.status_code == 201 + methods = [c[0][0] for c in session.request.call_args_list] + assert methods == ["GET", "POST", "GET", "POST"] + retried_post = session.request.call_args_list[3] + assert retried_post[1]["headers"]["X-CSRF-Token"] == "fresh" + + def test_403_retry_gives_up_after_one_attempt(self) -> None: + client, _, session = self._csrf_client() + session.request.side_effect = [ + self._csrf_fetch_response("t1"), # probe + _make_response(403), # POST rejected + self._csrf_fetch_response("t2"), # re-probe + _make_response(403), # retried POST still 403 — return as-is + ] + resp = client.request(HttpMethod.POST, "/items", json={"a": 1}) + assert resp.status_code == 403 + assert session.request.call_count == 4 + + def test_get_request_does_not_trigger_csrf(self) -> None: + client, _, session = self._csrf_client() + session.request.return_value = _make_response(200) + client.request(HttpMethod.GET, "/items") + assert session.request.call_count == 1 + assert session.request.call_args[0][0] == "GET" + + class TestXsuaaAuthProviderSubdomainValidation: def _make_config(self, identityzone: str = "provider-zone") -> MagicMock: diff --git a/uv.lock b/uv.lock index 3be64207..1498f8ce 100644 --- a/uv.lock +++ b/uv.lock @@ -315,6 +315,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/5c/f15aa7a5da42fc85254f0150df7d377bb965b0c130c68f9701fba88e0df4/botocore-1.43.87-py3-none-any.whl", hash = "sha256:01e1e2255a8f4b959c19b210d74a5a916d13841c0344ceee15d8a3eaa4daa892", size = 15755750, upload-time = "2026-09-02T19:23:05.937Z" }, ] +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + [[package]] name = "cel-python" version = "0.5.0" @@ -4353,6 +4362,7 @@ name = "sap-cloud-sdk" version = "0.62.0" source = { editable = "." } dependencies = [ + { name = "cachetools" }, { name = "cryptography" }, { name = "grpcio" }, { name = "hatchling" }, @@ -4443,6 +4453,7 @@ dev = [ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0,<1.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.9.0,<4" }, + { name = "cachetools", specifier = "~=5.5.2" }, { name = "cryptography", specifier = ">=48.0.1,<51" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0,<6" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0,<1" },