Skip to content

feat(adms): add SAP ADMS module with sync/async clients and BDD tests#138

Open
adwitiyasushant wants to merge 49 commits into
SAP:mainfrom
adwitiyasushant:contrib/adms
Open

feat(adms): add SAP ADMS module with sync/async clients and BDD tests#138
adwitiyasushant wants to merge 49 commits into
SAP:mainfrom
adwitiyasushant:contrib/adms

Conversation

@adwitiyasushant
Copy link
Copy Markdown

@adwitiyasushant adwitiyasushant commented May 26, 2026

Disclaimer: Do not include SAP-internal or customer-specific information in this PR (e.g. internal system URLs, customer names, tenant IDs, or confidential configurations). This is a public repository.

Description

Adds the ADMS (Advanced Document Management Service) module to the
SAP Cloud SDK for Python.

Core components

  • AdmsClient (sync) and AsyncAdmsClient (async) with sub-namespaces
    for documents, relations, jobs, and configuration reads
  • create_client() / create_async_client() factories that load IAS
    bindings from a mounted secret volume with environment-variable fallback
  • IAS X.509 (X509_GENERATED) client-credentials authentication, with
    optional resource scoping for ADM application audience binding
  • OData V4 support across DocumentService, AdminService, and
    ConfigurationService paths

Features

  • Document CRUD, content download URLs, and scan-state handling
    (PENDING / CLEAN / INFECTED)
  • DocumentRelation lifecycle including draft create / validate /
    activate / discard
  • Configuration reads for allowed domains, document types, and
    business-object node types
  • ZIP_DOWNLOAD and DELETE_USER_DATA job orchestration via
    AdminService

Supporting infrastructure (in core/)

  • IasTokenFetcherclient_credentials (cached) and jwt-bearer
    on-behalf-of (not cached, by design) grant flows
  • AdmsHttp / AsyncAdmsHttp — typed HTTP helpers with OData error
    mapping, X-CSRF-Token fetch-and-carry (per service root), and
    thread- / coroutine-safe CSRF caching
  • MTLSStrategy — X.509 mTLS for services using
    accessStrategy: sap:cmp-mtls:v1, with explicit close() and
    context-manager lifecycle
  • Pluggable TokenCache interface with InMemoryTokenCache and
    RedisTokenCache (multi-pod thundering-herd mitigation)
  • PEP 561 py.typed markers on core.auth, core.http, adms

Related Issue

N/A — this is a new module addition with no tracked issue.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Code refactoring
  • Dependency update

How to Test

  1. Unit tests (no credentials required):
    uv run python -m pytest tests/adms/unit/ tests/core/unit/auth/ tests/core/unit/http/ -q
    

Expected: all unit tests pass.

  1. Lint and type checks:

uv run ruff check src/sap_cloud_sdk/
uv run ty check --python-version 3.11 src/
Expected: clean.

  1. Integration tests (requires a real ADM instance):
    Set the standard ADMS env vars (or mount the secret volume):

CLOUD_SDK_CFG_ADMS_DEFAULT_URL (IAS tenant URL)
CLOUD_SDK_CFG_ADMS_DEFAULT_URI (ADM service URL)
CLOUD_SDK_CFG_ADMS_DEFAULT_CLIENTID
CLOUD_SDK_CFG_ADMS_DEFAULT_CLIENTSECRET
CLOUD_SDK_CFG_ADMS_DEFAULT_RESOURCE (optional)

Then:
uv run python -m pytest tests/adms/integration/ -m integration -v

Expected: all 17 BDD scenarios pass — sync + async document/relation
CRUD, draft flow, scan-state, concurrent creates, and 404 paths.

Checklist

  • I have read the Contributing Guidelines
  • I have verified that my changes solve the issue
  • I have added/updated automated tests to cover my changes
  • All tests pass locally
  • I have verified that my code follows the Code Guidelines
  • I have updated documentation (if applicable) — src/sap_cloud_sdk/adms/user-guide.md
  • I have added type hints for all public APIs
  • My code does not contain sensitive information (credentials, tokens, etc.)
  • I have followed Conventional Commits for commit messages

Breaking Changes
None. This PR is purely additive — it introduces a new adms package and adds helpers under core.auth / core.http without modifying any existing public API.

Additional Notes

  • The OBO (jwt-bearer) flow is intentionally not cached — OBO tokens are scoped to a specific end-user JWT, and cross-pod sharing would be a privilege boundary violation. There is a unit test that pins this contract (test_obo_and_cc_caches_are_isolated).
  • RedisTokenCache failures are non-fatal but logged at WARNING with exc_info=True so a misconfigured cache surfaces in logs rather than silently degrading every pod into independent IAS hammering.
  • All OData V4 string keys are escaped per §5.1.1.6.2 via quote_odata_string_key() to prevent URL-injection through user-supplied IDs.

@adwitiyasushant adwitiyasushant requested a review from a team as a code owner May 26, 2026 13:10
@cla-assistant
Copy link
Copy Markdown

cla-assistant Bot commented May 26, 2026

CLA assistant check
All committers have signed the CLA.

Adds a full-featured ADMS (Attachment Document Management Service) module
to the SDK with sync and async clients, IAS X.509 token authentication,
OData V4 service support (DocumentService, ConfigurationService,
AdminService), and pytest-bdd integration tests with Gherkin scenarios.

Also adds shared SDK building blocks consumed by ADMS: IAS token fetcher,
mTLS support, async HTTP client, and the ADMS telemetry module entry.
Required by upstream's "Enforce version bump when src/ is modified" CI check.
- Revert global [tool.pytest.ini_options] integration marker description and
  remove asyncio_mode=auto so the change does not leak into other modules'
  test runs.
- Extract /etc/secrets/appfnd and CLOUD_SDK_CFG as module-level constants in
  adms/config.py for consistency with the existing _SERVICE_PATH /
  _ADMIN_SERVICE_PATH constants.
Upstream's data-anonymization PR (SAP#93) also bumped to 0.20.0 — bumping
to 0.20.1 to satisfy the version-bump CI check on the rebased branch.
Required for pytest-asyncio strict mode (the project default after
asyncio_mode auto was scoped out). Matches the convention already in
use in tests/agentgateway/.
…trings

Leftover wording from the dms module template — exception, config, auth and
http docstrings now correctly identify themselves as ADMS. No behavioural
change.
Comment thread docs/adms/patterns/delete_user_data_pattern.yaml Outdated
Comment thread docs/adms/patterns/document_download_pattern.yaml Outdated
Comment thread docs/adms/patterns/document_upload_pattern.yaml Outdated
Comment thread docs/adms/patterns/draft_lifecycle_pattern.yaml Outdated
Comment thread docs/adms/patterns/zip_job_pattern.yaml Outdated
Comment thread src/sap_cloud_sdk/adms/_auth.py Outdated
Comment thread src/sap_cloud_sdk/core/auth/_token_cache.py Outdated
Comment thread src/sap_cloud_sdk/core/http/_async_client.py Outdated
Comment thread src/sap_cloud_sdk/core/http/_batch.py Outdated
Comment thread tests/adms/unit/test_client.py Outdated
- Add ADMS placeholder block to .env_integration_tests.example so reviewers
  know which env vars to set for external/BTP mode.
- Correct the env var names in conftest docstring and INTEGRATION_TESTS_ADMS.md:
  the loader expects CLIENTID / CLIENTSECRET / URL / URI (matching the IAS
  binding field names used by destination/), not the underscored CLIENT_ID /
  IAS_URL / SERVICE_URL variants — those would have failed with KeyError.
- Add the optional RESOURCE entry (IAS resource URI) the docs were missing.
- Rename leftover "DMS Integration Tests" heading to "ADMS Integration Tests".
- Remove unused re-exports from adms/_auth.py (_CC_CACHE_KEY, _GRANT_JWT_BEARER)
- Make InMemoryTokenCache thread-safe via threading.Lock
- Drop module-specific reference from core/http async client docstring
- Delete unused core/http/_batch.py and its tests/BDD scenarios
- Remove 5 pattern YAML stubs from docs/adms/patterns/
- Restore underscore names for internal API classes in adms unit tests
Per PR review: integration tests target real service instances, so the
ADMS-specific guide duplicates the canonical doc. Merge env vars into
docs/INTEGRATION_TESTS.md and delete docs/INTEGRATION_TESTS_ADMS.md.
Per PR review. Local-only test driver — kept on developer machines via
.gitignore but no longer shipped with the SDK.
… pattern

Per PR review:
- Drop the special CLOUD_SDK_ADMS_INTEGRATION_URL block from the workflow.
  ADMS now flows through the existing CLOUD_SDK_CFG_* loader like every
  other module.
- Rewrite tests/adms/integration/conftest.py to target real ADM instances
  only. Removes the local HDM Maven auto-start mode (~150 lines), the
  mock IasTokenFetcher, and the CLOUD_SDK_ADMS_SKIP_IF_UNAVAILABLE flag.
- Skip the suite gracefully when load_from_env_or_mount() raises
  ConfigError because of missing credentials.
- Update docs and .env_integration_tests.example to match.
The concurrent-create integration test built coroutines and called
asyncio.gather() from sync code, binding the resulting future to the
default event loop. The run_async fixture then ran it in a separate
loop, raising "future belongs to a different loop" against a real
ADM instance.

Wrap the gather in an async helper, mirroring the existing
cleanup_concurrent_async_relations pattern, so coroutines and gather
share the run_async fixture's loop.
…example

Per PR review. The 'resource' field example pointed to a personal IAS
application name; replace with the generic 'my-adm-app'.
Per PR review. Replace internal-only "Unified Provisioning / UCL" and
"BTP Fabric SDK Business Services TRA" references with the generic
"BTP service instance" wording suitable for a public SDK. Also fix the
stale "DMS" tag on the first line; the module is ADMS.
Per PR review. The defensive untyped parameter and "avoid circular
import" comment were incorrect — config.py imports only from core and
exceptions, no cycle exists. Add the AdmsConfig import and annotate
the parameter properly.
Per PR review. HttpError was imported twice — once bare and once aliased
as AdmsHttpError. Since CoreHttpError is already aliased on the line
below, the bare HttpError name is unambiguous. Drop the duplicate alias
and use HttpError consistently.
Per PR review. Replace `from sap_cloud_sdk.core.auth._token_cache import
TokenCache` with the public re-export `from sap_cloud_sdk.core.auth
import TokenCache` in client.py and __init__.py to avoid reaching into
the private `_token_cache` module.
- Expand "ADM Document Management Service" to "Advanced Document
  Management Service" in _models.py docstring (consistent with the
  rest of the module).
- Drop SAP-internal "Unified Provisioning / UCL" wording in
  user-guide.md; use the public "BTP service instance" phrasing
  already used in __init__.py.
Per PR review.

* PEP 8: rename `mTLSConfig` → `MTLSConfig` and `mTLSStrategy` →
  `MTLSStrategy`. Public-facing classes now follow the standard PascalCase
  rule. All callers, BDD scenarios, step definitions, error messages and
  test class names are updated.
* Drop the unused `client: Optional[httpx.AsyncClient] = None` parameter
  from `MTLSStrategy.apply_to_async_client`. The previous signature
  documented the argument as "Ignored (kept for API symmetry)" — it
  was never honoured because httpx does not allow swapping the SSL
  context on an existing client. The misleading parameter is removed
  and the docstring rewritten accordingly. Module docstring example
  and BDD step are updated to call without the argument.
Per PR review. `_CC_CACHE_KEY` and `_GRANT_JWT_BEARER` are private
constants of `_ias_fetcher` and should not appear in the public
`sap_cloud_sdk.core.auth` namespace.

Drop them from the import list and `__all__` in core/auth/__init__.py,
and update tests/adms/unit/test_auth.py to import `_CC_CACHE_KEY`
directly from the private module (matching the existing pattern in
tests/core/unit/auth/test_ias_fetcher.py).
* tests/adms/integration/test_e2e_async_flow.py — bind narrowed
  context.client / context.bo_type_id to local variables before the
  nested `_gather` async closure. ty re-widens optional attributes
  inside nested closures, so the outer-function asserts no longer
  apply once the closure references them. Local-variable binding
  preserves narrowing.

* pyproject.toml + uv.lock — bump version 0.21.1 → 0.22.0.
  Required by check-version-bump CI now that the merge with main has
  introduced src/ changes (new ADMS module). Minor bump because this
  adds a new optional module.
Comment thread src/sap_cloud_sdk/adms/user-guide.md
Resolves three conflicts from the parallel agentgateway work landing on main:

- .env_integration_tests.example — keep both ADMS and Agent Gateway env var blocks
- docs/INTEGRATION_TESTS.md — keep both ADMS and Agent Gateway sections
- tests/core/unit/telemetry/test_operation.py — combined operation count
  (4 agentgateway from upstream + 33 adms from this branch = 124)
Required by the CI version-bump check after merging upstream/main
(which already shipped 0.22.0). Bumping minor since this PR still
introduces the new ADMS module.
TokenCache is a generic auth utility from sap_cloud_sdk.core.auth and
is not ADMS-specific. Re-exporting it from the adms namespace created
two import paths for the same class. Users should import it directly
from core.auth. Also drops the unused TokenCache import in user-guide.md
(only RedisTokenCache was actually used in the example).
Comment thread src/sap_cloud_sdk/core/auth/_ias_fetcher.py Outdated
Comment thread src/sap_cloud_sdk/core/auth/__init__.py Outdated
Comment thread src/sap_cloud_sdk/core/http/__init__.py Outdated
Comment thread src/sap_cloud_sdk/adms/_http.py
Comment thread src/sap_cloud_sdk/adms/_http.py Outdated
Comment thread src/sap_cloud_sdk/adms/_http.py Outdated
@ArthurTonial
Copy link
Copy Markdown
Member

And just a small thing, please, can you update the PR description so it follows the template?

CSRF tokens have a server-side TTL and may expire between cached
fetches. When a mutating request (POST/PATCH/DELETE) returns 403,
evict the cached token, re-fetch, and retry once before raising.

Also adds a PEP 561 py.typed marker to core/http for downstream type
checkers.
…leak

AsyncAdmsHttp.with_user_jwt previously constructed a new instance
without forwarding the underlying httpx.AsyncClient, allocating a
fresh connection pool per user-scoped call. The returned wrapper
had no parent close path, so each call leaked a pool — a real
problem in handlers that fan out to client.with_user_jwt(jwt).x().

Now the user-scoped wrapper borrows the parent's client and is
marked non-owning so closing it is a no-op; the parent retains
ownership and closes once. Also expands the sync _get_csrf_token
docstring to mirror the async sibling's reasoning about skipping
error checks on the bare service-root response.
- Sort the exceptions group of __all__ alphabetically (AdmsError /
  AdmsOperationError now precede AuthError) for consistency with the
  surrounding groups.
- Replace getattr(config, "resource", None) with config.resource —
  AdmsConfig is a dataclass with a typed `resource: str | None = None`
  field, so the attribute is always present.
The async _AsyncJobApi.get_status was hard-coded to DocumentService, so
async callers polling a DELETE_USER_DATA job (started against AdminService
via start_delete_user_data) hit the wrong path and got a 404. Sync sibling
already accepted use_admin_service: bool — restore parity.

Add an async unit test that asserts AdminService routing when
use_admin_service=True.
User-supplied IDs (job_id, document_type_id, etc.) were f-string
interpolated directly into single-quote-wrapped OData entity keys. A
value containing a single quote would break the URL or alter query
intent — e.g. an ID like ``x'); ...`` could in principle target a
different entity than the caller intended.

Add ``quote_odata_string_key`` helper (OData V4 §5.1.1.6.2 — single
quotes inside string literals must be doubled) and apply it at all 8
call sites in client.py (sync + async).

Also adds 4 unit tests covering simple values, embedded quotes,
injection neutralisation, and the empty string.
The CSRF token cache (``self._csrf_tokens``) was read, written, and (on
the 403-retry path) popped without synchronisation.  ``AdmsClient`` is a
natural candidate for shared use across threads — ``requests.Session``
is documented thread-safe and users will reuse one client from a thread
pool — so the unguarded check-then-act sequence in ``_get_csrf_token``
and the unconditional ``pop`` in the 403-retry path race readers.

Sync side: add a ``threading.Lock``; guard the read and the
``setdefault``-based write.  The 403 retry path now only evicts the
cached token if it still matches the value that 403'd, so it cannot
clobber a token a sibling thread just refreshed.

Async side: add an ``asyncio.Lock`` with the same pattern for parallel
coroutines on a single event loop.

The fetch itself is performed *outside* the lock, so the lock blocks
only on cheap dict ops.  Class docstrings now declare the thread- /
coroutine-safety contract explicitly.

Tests: two regression tests covering convergence under concurrency
and the "evict-only-if-stale" guard.
H3 — IAS ``int(expires_in)`` propagated raw ``ValueError`` / ``TypeError``
when a misbehaving proxy or IAS implementation returned ``"3600"`` (string)
or ``null`` for ``expires_in``.  Callers using ``except AuthError`` saw
the raw built-in exception instead, bypassing retry envelopes.  Wrap the
conversion and re-raise as ``AuthError`` with a descriptive message.

H4 — async ``_get_csrf_token`` had no explicit timeout; behaviour depended
on whatever the caller-supplied ``httpx.AsyncClient`` was constructed with,
so sync (10s) and async clients could behave very differently under load.
Pass an explicit ``_CSRF_FETCH_TIMEOUT_SECONDS`` like the sync sibling.

H5 — extract magic ``10`` and ``30`` second timeouts in ADMS ``_http.py``
to named constants (``_CSRF_FETCH_TIMEOUT_SECONDS``, ``_REQUEST_TIMEOUT_SECONDS``)
documenting the asymmetry.

H8 — ``response_text`` on ``HttpError`` / ``NotFoundError`` carried the
*full* response body, which can be 50KB+ HTML from a misconfigured ingress.
Truncate consistently to 500 chars (extracted to a named constant) on both
the async core client and the sync ADMS HTTP wrapper.

Tests: two regression tests pinning the IAS ``expires_in`` envelope for
non-integer and null values.
``RedisTokenCache`` swallowed all exceptions silently in ``get`` /
``set`` / ``delete``.  When Redis is misconfigured (wrong password, TLS
mismatch, ACL denial, network partition), the cache silently degrades
to "always miss + always fail-write" — yet the whole reason to enable
Redis is to share tokens across pods and avoid IAS thundering-herd.
Operators get zero signal that the cache is broken.

Log at WARNING with ``exc_info=True`` so the misconfiguration surfaces
in operator dashboards, while keeping the cache failures non-fatal.

Tests: extended the three existing failure-path tests to assert
WARNING records are emitted with the expected method-prefixed message.
Fourteen ``to_odata_dict`` methods on public dataclasses (in ``__all__``)
were undocumented — readers had to read the body to understand the OData
payload contract.  Add a one-line docstring to each.  ``DraftActivateInput``
gets a slightly longer note since it extends the parent and adds an extra
optional field.
M1 — ``create_client`` and ``create_async_client`` wrapped any
``Exception`` in ``ClientCreationError``.  Because ``load_from_env_or_mount``
already maps real config failures to ``ConfigError`` and explicit
``ValueError`` paths re-raise unchanged, the broad except only ever
caught internal SDK bugs (``KeyError``, ``AttributeError``, ``TypeError``)
and reported them to callers as "client creation failed", masking the
real fault.  Drop the catch-all so genuine bugs surface as themselves.

The ``ClientCreationError`` symbol remains exported from
``sap_cloud_sdk.adms.exceptions`` for any caller still catching it, but
the factories no longer raise it.  Update the test that asserted the
wrapping behaviour to assert pass-through instead.

M2 — ``_BindingData.validate`` iterated a hard-coded list of field names
via ``getattr(self, f)``.  Since the list is static and matches the
dataclass shape, switch to direct attribute access (a list of
``(name, value)`` tuples) — both clearer and friendlier to type checkers.
…Strategy

Private-key material was previously cleaned up only via __del__, which is
best-effort: held references, GC delays, or interpreter shutdown can
postpone or skip cleanup. On long-lived containers with shared /tmp
mounts (sidecar containers, host-bind mounts), private-key bytes can
linger longer than necessary.

- Add close() that deletes tracked temp files; idempotent and safe to
  call multiple times.
- Add __enter__/__exit__ so the strategy works as a context manager,
  giving deterministic cleanup on both happy and exception paths.
- Document the lifecycle in the class docstring; recommend the
  context-manager form.
- Keep __del__ as a best-effort safety net; log at DEBUG when it runs
  without an explicit close() so operators can spot non-deterministic
  cleanup if it matters.

Tests cover explicit close, idempotency, context-manager exit (happy
and exception), and reuse-after-close.
…O/CC cache isolation

- AsyncHttpClient: assert __aexit__ runs aclose when the body raises;
  assert real httpx.AsyncClient.aclose is safe to call twice (the
  protocol can result in double-cleanup in caller code).
- AsyncAdmsHttp: same exception-path coverage; assert aclose is
  idempotent on the owned client.
- IasTokenFetcher: assert interleaving get_token (cached) and
  exchange_token (not cached) keeps the two grant types isolated —
  no cache-key collision and exactly one client_credentials IAS hit
  across the sequence. Guards the OBO privilege boundary against a
  future regression where someone adds caching to exchange_token.
Comment thread src/sap_cloud_sdk/adms/client.py Outdated
Comment thread src/sap_cloud_sdk/adms/client.py Outdated
Comment thread src/sap_cloud_sdk/adms/client.py Outdated
Comment thread src/sap_cloud_sdk/adms/client.py Outdated
Reviewer ArthurTonial flagged AllowedDomainID as missing the OData escape
that H1 added. Audit found the same pattern in 27 sites: AllowedDomainID,
DocumentRelationID, and DocumentTypeBOTypeMapID are all Edm.Guid keys
(per their docstrings — "Primary key UUID"), interpolated into the URL
unquoted.

The string-key escape isn't the right tool for Edm.Guid: OData V4 §5.1.1.6.2
reserves single-quoted literals for Edm.String, and SAP CAP rejects a
quoted Guid as a type mismatch. Injection protection on Guid keys must
come from validation, not escaping.

- Add quote_odata_guid_key() that parses with uuid.UUID and raises
  ValueError on malformed input. Returns the canonical lowercase
  8-4-4-4-12 form, unquoted, ready to drop into a key segment.
- Apply across all 27 unquoted-Guid call sites (sync + async variants
  of DocumentRelation, AllowedDomain, DocumentTypeBOTypeMap paths).
- Update unit-test fixtures to use real UUIDs where they pass through
  the new validator (placeholders like "rel-1" / "ad-uuid-1" now fail
  validation, as they should).

Verified: 269 unit tests pass, all 17 integration tests still pass
against the live tenant — wire format is unchanged.
- Bump project version per the CI version-bump check (src/ changed,
  so the version must move; 0.23.1 → 0.23.2).
- Reformat the adms client import block to multi-line form so it
  satisfies `ruff format` line-length rules.
Comment thread src/sap_cloud_sdk/adms/_auth.py Outdated
Per reviewer feedback, the contribution should be self-contained inside the
adms/ package. Relocate IAS auth, token cache, and async HTTP modules into
adms/, remove the orphaned MTLSStrategy that was never wired into ADMS, and
revert unrelated drift in core/ (PEP 563 tolerance in secret_resolver, the
auditlog conftest fail->skip change).

- Move src/sap_cloud_sdk/core/auth/_ias_fetcher.py -> adms/_ias_fetcher.py
- Move src/sap_cloud_sdk/core/auth/_token_cache.py -> adms/_token_cache.py
- Move src/sap_cloud_sdk/core/http/_async_client.py -> adms/_async_http.py
- Move corresponding unit tests into tests/adms/unit/
- Delete core/auth/_mtls.py (orphan — never imported by ADMS)
- Delete tests/core/unit/bdd/ (BDD duplicates of unit-tested behaviour)
- Drop `from __future__ import annotations` from adms/config.py so the
  reverted secret_resolver.py keeps working without the PEP 563 workaround
- Update all imports + sphinx :class: references in adms/ docstrings and
  the user-guide example
Per reviewer feedback (NicoleMGomes, PR SAP#138): after the relocation
refactor moved IasTokenFetcher into the adms package, the _auth wrapper
(config adaptation + AuthError conversion) became pure boilerplate
wrapping a sibling class. Inline the adaptation into _ias_fetcher
(constructor now takes AdmsConfig directly) and drop the duplicate
AuthError by importing from adms.exceptions. Consolidates two
overlapping test files into test_ias_fetcher.py.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants