diff --git a/src/multisafepay/client/__init__.py b/src/multisafepay/client/__init__.py index a14961a..922e1d2 100644 --- a/src/multisafepay/client/__init__.py +++ b/src/multisafepay/client/__init__.py @@ -2,8 +2,10 @@ from multisafepay.client.api_key import ApiKey from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ScopedCredentialResolver __all__ = [ "ApiKey", "Client", + "ScopedCredentialResolver", ] diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index d6d5e00..ec83993 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -16,6 +16,11 @@ from ..exception.api import ApiException from .api_key import ApiKey +from .credential_resolver import ( + AuthScope, + CredentialResolver, + ScopedCredentialResolver, +) class Client: @@ -46,18 +51,20 @@ class Client: def __init__( self: "Client", - api_key: str, - is_production: bool, + api_key: Optional[str] = None, + is_production: bool = False, transport: Optional[HTTPTransport] = None, locale: str = "en_US", base_url: Optional[str] = None, + credential_resolver: Optional[CredentialResolver] = None, ) -> None: """ Initialize the Client. Parameters ---------- - api_key (str): The API key for authentication. + api_key (Optional[str]): The API key for authentication. + Optional only when `credential_resolver` is provided. is_production (bool): Flag indicating if the client is in production mode. transport (Optional[HTTPTransport], optional): Custom HTTP transport implementation. Defaults to RequestsTransport if not provided. @@ -65,9 +72,21 @@ def __init__( base_url (Optional[str], optional): Custom API base URL. Only allowed when running with `MSP_SDK_BUILD_PROFILE=dev` and `MSP_SDK_ALLOW_CUSTOM_BASE_URL=1`. + credential_resolver (Optional[CredentialResolver], optional): + Resolver used to derive API keys by auth scope. + + Raises + ------ + ValueError: If no API key or CredentialResolver is provided. """ - self.api_key = ApiKey(api_key=api_key) + if api_key is None and credential_resolver is None: + raise ValueError( + "api_key is required when credential_resolver is not provided.", + ) + + self.api_key = ApiKey(api_key=api_key) if api_key is not None else None + self.credential_resolver = credential_resolver self.url = self._resolve_base_url( is_production=is_production, explicit_base_url=base_url, @@ -123,6 +142,7 @@ def create_get_request( endpoint: str, params: dict[str, Any] = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a GET request. @@ -143,6 +163,7 @@ def create_get_request( self.METHOD_GET, url, context=context, + auth_scope=auth_scope, ) def create_post_request( @@ -151,6 +172,7 @@ def create_post_request( params: dict[str, Any] = None, request_body: str = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a POST request. @@ -173,6 +195,7 @@ def create_post_request( url, request_body=request_body, context=context, + auth_scope=auth_scope, ) def create_patch_request( @@ -181,6 +204,7 @@ def create_patch_request( params: dict[str, Any] = None, request_body: str = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a PATCH request. @@ -203,6 +227,7 @@ def create_patch_request( url, request_body=request_body, context=context, + auth_scope=auth_scope, ) def create_delete_request( @@ -210,6 +235,7 @@ def create_delete_request( endpoint: str, params: dict[str, Any] = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a DELETE request. @@ -226,7 +252,12 @@ def create_delete_request( """ url = self._build_url(endpoint, params) - return self._create_request(self.METHOD_DELETE, url, context=context) + return self._create_request( + self.METHOD_DELETE, + url, + context=context, + auth_scope=auth_scope, + ) def _build_url( self: "Client", @@ -255,12 +286,33 @@ def _build_url( ) return f"{self.url}{endpoint}?{query_string}" + def _resolve_api_key( + self: "Client", + auth_scope: Optional[AuthScope], + ) -> str: + if self.credential_resolver is not None: + resolved_scope = auth_scope or AuthScope( + scope=ScopedCredentialResolver.AUTH_SCOPE_DEFAULT, + ) + return self.credential_resolver.resolve( + auth_scope=resolved_scope.scope, + group_id=resolved_scope.group_id, + ) + + if self.api_key is None: + raise ValueError( + "api_key is required when credential_resolver is not provided.", + ) + + return self.api_key.get() + def _create_request( self: "Client", method: str, url: str, request_body: Optional[dict[str, Any]] = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create and send an HTTP request. @@ -277,9 +329,10 @@ def _create_request( ApiResponse: The API response. """ + api_key = self._resolve_api_key(auth_scope) headers = { - "Authorization": "Bearer " + self.api_key.get(), - "accept-encoding": "application/json", + "Authorization": "Bearer " + api_key, + "Accept": "application/json", "Content-Type": "application/json", } diff --git a/src/multisafepay/client/credential_resolver.py b/src/multisafepay/client/credential_resolver.py new file mode 100644 index 0000000..860bcaa --- /dev/null +++ b/src/multisafepay/client/credential_resolver.py @@ -0,0 +1,106 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Credential resolver contracts and default scoped resolver.""" + +from dataclasses import dataclass +from typing import Optional, Protocol + + +@dataclass(frozen=True) +class AuthScope: + """Auth scope selection payload for credential resolution.""" + + scope: str + group_id: Optional[str] = None + + +class CredentialResolver(Protocol): + """Protocol for resolving API keys by auth scope and context.""" + + def resolve( + self: "CredentialResolver", + auth_scope: str, + group_id: Optional[str] = None, + ) -> str: + """Resolve the API key to use for a given scope and context.""" + + +class ScopedCredentialResolver: + """Default resolver implementation for account, partner and group scopes.""" + + AUTH_SCOPE_DEFAULT = "default_account" + AUTH_SCOPE_PARTNER_AFFILIATE = "partner_affiliate" + AUTH_SCOPE_TERMINAL_GROUP = "terminal_group" + + def __init__( + self: "ScopedCredentialResolver", + default_api_key: str, + partner_affiliate_api_key: Optional[str] = None, + terminal_group_api_keys: Optional[dict[str, str]] = None, + ) -> None: + """ + Initialize a scoped credential resolver. + + Parameters + ---------- + default_api_key (str): Fallback/default account API key. + partner_affiliate_api_key (Optional[str]): Partner/affiliate API key. + terminal_group_api_keys (Optional[dict[str, str]]): Mapping of + terminal_group_id to API key. + + """ + self.default_api_key = (default_api_key or "").strip() + self.partner_affiliate_api_key = ( + partner_affiliate_api_key or "" + ).strip() or None + self.terminal_group_api_keys = { + group_id: api_key.strip() + for group_id, api_key in (terminal_group_api_keys or {}).items() + if api_key and api_key.strip() + } + + if ( + not self.default_api_key + and self.partner_affiliate_api_key is None + and not self.terminal_group_api_keys + ): + raise ValueError( + "ScopedCredentialResolver requires at least one API key.", + ) + + def resolve( + self: "ScopedCredentialResolver", + auth_scope: str, + group_id: Optional[str] = None, + ) -> str: + """Resolve API key for the given scope and auth context.""" + if auth_scope == self.AUTH_SCOPE_TERMINAL_GROUP: + if not group_id: + raise ValueError( + "Missing terminal_group_id in auth scope.", + ) + api_key = self.terminal_group_api_keys.get(group_id) + if not api_key: + raise ValueError( + "No API key configured for terminal_group_id " + f"'{group_id}'.", + ) + return api_key + + if auth_scope == self.AUTH_SCOPE_PARTNER_AFFILIATE: + api_key = self.partner_affiliate_api_key or self.default_api_key + if not api_key: + raise ValueError( + "No API key configured for partner_affiliate scope.", + ) + return api_key + + if not self.default_api_key: + raise ValueError("No API key configured for default scope.") + + return self.default_api_key diff --git a/src/multisafepay/sdk.py b/src/multisafepay/sdk.py index b22dc3b..d835822 100644 --- a/src/multisafepay/sdk.py +++ b/src/multisafepay/sdk.py @@ -26,6 +26,7 @@ from .api.paths.me.me_manager import MeManager from .api.paths.recurring.recurring_manager import RecurringManager from .client.client import Client +from .client.credential_resolver import CredentialResolver class Sdk: @@ -38,19 +39,21 @@ class Sdk: def __init__( self: "Sdk", - api_key: str, - is_production: bool, + api_key: Optional[str] = None, + is_production: bool = False, transport: Optional[HTTPTransport] = None, locale: str = "en_US", base_url: Optional[str] = None, + credential_resolver: Optional[CredentialResolver] = None, ) -> None: """ Initialize the SDK with the provided configuration. Parameters ---------- - api_key : str + api_key : Optional[str] The API key for authenticating with the MultiSafePay API. + Optional only when `credential_resolver` is provided. is_production : bool Flag indicating whether to use the production environment. transport : Optional[HTTPTransport], optional @@ -60,14 +63,21 @@ def __init__( The locale to use for requests, by default "en_US". base_url : Optional[str], optional Custom API base URL (dev-only guardrails apply), by default None. + credential_resolver : Optional[CredentialResolver], optional + Strategy for resolving API keys per auth scope, by default None. + + Raises + ------ + ValueError: If no API key or CredentialResolver is provided. """ self.client = Client( - api_key.strip(), - is_production, - transport, - locale, - base_url, + api_key=api_key, + is_production=is_production, + transport=transport, + locale=locale, + base_url=base_url, + credential_resolver=credential_resolver, ) self.recurring_manager = RecurringManager(self.client) diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 92af510..b4bbc90 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -11,10 +11,61 @@ import pytest from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ( + ScopedCredentialResolver, +) from multisafepay.transport import RequestsTransport requests = pytest.importorskip("requests") +DEFAULT_API_KEY = "default_api_key" +TERMINAL_GROUP_ID = "Default" +TERMINAL_GROUP_API_KEY = "terminal_group_api_key" +ORDERS_ENDPOINT = "json/orders" +API_KEY_REQUIRED_ERROR = "api_key is required" + + +class _FakeResponse: + """Small HTTP response stub for unit tests.""" + + status_code = 200 + headers = {} + + @staticmethod + def json() -> dict: + return { + "success": True, + "data": {}, + } + + @staticmethod + def raise_for_status() -> None: + return + + +class _CaptureTransport: + """Transport stub that captures the request headers.""" + + def __init__(self: "_CaptureTransport") -> None: + self.headers = {} + + def request(self: "_CaptureTransport", **kwargs: dict) -> _FakeResponse: + self.headers = kwargs.get("headers", {}) + return _FakeResponse() + + +def _build_resolver_client( + resolver: ScopedCredentialResolver, + transport: _CaptureTransport, +) -> Client: + """Build a client configured for resolver-based auth tests.""" + return Client( + api_key=None, + is_production=False, + transport=transport, + credential_resolver=resolver, + ) + def test_initializes_with_default_requests_transport(): """Test that the Client initializes with the default requests transport.""" @@ -211,3 +262,71 @@ def test_rejects_custom_base_url_without_netloc( is_production=False, base_url="https:///v1", ) + + +def test_create_get_request_sends_authorization_header() -> None: + """GET request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_get_request("json/orders") + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_create_post_request_sends_authorization_header() -> None: + """POST request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_post_request("json/orders", request_body='{"foo":"bar"}') + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_create_patch_request_sends_authorization_header() -> None: + """PATCH request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_patch_request("json/orders/1", request_body='{"foo":"bar"}') + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_create_delete_request_sends_authorization_header() -> None: + """DELETE request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_delete_request("json/recurring/1") + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_resolve_api_key_uses_credential_resolver() -> None: + """Prefer credential resolver when both api_key and resolver exist.""" + transport = _CaptureTransport() + resolver = ScopedCredentialResolver(default_api_key="resolver_key") + client = _build_resolver_client(resolver, transport) + client.create_get_request("json/orders") + assert transport.headers["Authorization"] == "Bearer resolver_key" + + +def test_resolve_api_key_raises_without_key_or_resolver() -> None: + """Raise ValueError when no api_key or resolver is configured.""" + with pytest.raises(ValueError, match=API_KEY_REQUIRED_ERROR): + Client( + api_key=None, + is_production=False, + transport=_CaptureTransport(), + credential_resolver=None, + ) diff --git a/tests/multisafepay/unit/client/test_unit_credential_resolver.py b/tests/multisafepay/unit/client/test_unit_credential_resolver.py new file mode 100644 index 0000000..52d7f0e --- /dev/null +++ b/tests/multisafepay/unit/client/test_unit_credential_resolver.py @@ -0,0 +1,134 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for scoped credential resolver behavior.""" + +import pytest + +from multisafepay.client.credential_resolver import ScopedCredentialResolver + +DEFAULT_API_KEY = "default_api_key" +PARTNER_API_KEY = "partner_api_key" +TERMINAL_GROUP_ID = "Default" +TERMINAL_GROUP_API_KEY = "terminal_group_api_key" +MISSING_GROUP_ID_ERROR = "Missing terminal_group_id" +NO_DEFAULT_SCOPE_ERROR = "No API key configured for default scope" + + +def _resolver_with_terminal_group() -> ScopedCredentialResolver: + """Create resolver fixture data for terminal-group scope tests.""" + return ScopedCredentialResolver( + default_api_key=DEFAULT_API_KEY, + terminal_group_api_keys={ + TERMINAL_GROUP_ID: TERMINAL_GROUP_API_KEY, + }, + ) + + +def test_rejects_resolver_without_any_api_key() -> None: + """Require at least one API key across all resolver sources.""" + with pytest.raises( + ValueError, + match="requires at least one API key", + ): + ScopedCredentialResolver(default_api_key="") + + +def test_resolves_default_scope_with_default_api_key() -> None: + """Resolve default scope using the configured default API key.""" + resolver = ScopedCredentialResolver(default_api_key=DEFAULT_API_KEY) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_DEFAULT) + == DEFAULT_API_KEY + ) + + +def test_resolves_partner_scope_with_partner_api_key() -> None: + """Prefer partner key for partner_affiliate scope.""" + resolver = ScopedCredentialResolver( + default_api_key=DEFAULT_API_KEY, + partner_affiliate_api_key=PARTNER_API_KEY, + ) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_PARTNER_AFFILIATE) + == PARTNER_API_KEY + ) + + +def test_resolves_terminal_group_scope_with_group_key() -> None: + """Resolve terminal_group scope using group-specific API key mapping.""" + resolver = _resolver_with_terminal_group() + + assert ( + resolver.resolve( + ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + == TERMINAL_GROUP_API_KEY + ) + + +def test_raises_for_terminal_group_scope_without_group_id() -> None: + """Reject terminal_group scope when group_id is missing.""" + resolver = _resolver_with_terminal_group() + + with pytest.raises(ValueError, match=MISSING_GROUP_ID_ERROR): + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP) + + +def test_raises_for_default_scope_without_default_key() -> None: + """Reject default scope when no default key is configured.""" + resolver = ScopedCredentialResolver( + default_api_key="", + partner_affiliate_api_key=PARTNER_API_KEY, + ) + + with pytest.raises( + ValueError, + match=NO_DEFAULT_SCOPE_ERROR, + ): + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_DEFAULT) + + +def test_resolves_partner_scope_falls_back_to_default_key() -> None: + """Fall back to default_api_key for partner scope when no partner key.""" + resolver = ScopedCredentialResolver(default_api_key=DEFAULT_API_KEY) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_PARTNER_AFFILIATE) + == DEFAULT_API_KEY + ) + + +def test_raises_for_unknown_terminal_group_id() -> None: + """Reject terminal_group scope when the group_id is not configured.""" + resolver = _resolver_with_terminal_group() + + with pytest.raises(ValueError, match="No API key configured"): + resolver.resolve( + ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id="unknown_group", + ) + + +def test_strips_whitespace_from_api_keys() -> None: + """Strip leading/trailing whitespace from provided API keys.""" + resolver = ScopedCredentialResolver( + default_api_key=" key_with_spaces ", + partner_affiliate_api_key=" partner_spaces ", + ) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_DEFAULT) + == "key_with_spaces" + ) + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_PARTNER_AFFILIATE) + == "partner_spaces" + ) diff --git a/tests/multisafepay/unit/test_unit_sdk.py b/tests/multisafepay/unit/test_unit_sdk.py index 038338e..f160905 100644 --- a/tests/multisafepay/unit/test_unit_sdk.py +++ b/tests/multisafepay/unit/test_unit_sdk.py @@ -11,6 +11,38 @@ from multisafepay import Sdk from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ScopedCredentialResolver + +DEFAULT_API_KEY = "resolver_api_key" + + +class _FakeResponse: + """Small HTTP response stub for SDK transport tests.""" + + status_code = 200 + headers = {} + + @staticmethod + def json() -> dict: + return { + "success": True, + "data": {}, + } + + @staticmethod + def raise_for_status() -> None: + return + + +class _CaptureTransport: + """Transport stub that captures outbound request headers.""" + + def __init__(self: "_CaptureTransport") -> None: + self.headers = {} + + def request(self: "_CaptureTransport", **kwargs: dict) -> _FakeResponse: + self.headers = kwargs.get("headers", {}) + return _FakeResponse() def test_sdk_uses_test_url_by_default(monkeypatch: pytest.MonkeyPatch): @@ -64,3 +96,44 @@ def test_sdk_blocks_custom_base_url_in_release( is_production=False, base_url="https://dev-api.multisafepay.test/v1", ) + + +def test_sdk_allows_resolver_only_initialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Allow constructing SDK without api_key when resolver is provided.""" + monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False) + monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False) + + resolver = ScopedCredentialResolver(default_api_key="resolver_api_key") + + sdk = Sdk( + is_production=False, + credential_resolver=resolver, + ) + + assert sdk.get_client().url == Client.TEST_URL + + +def test_sdk_requires_api_key_or_resolver() -> None: + """Reject SDK initialization when both api_key and resolver are missing.""" + with pytest.raises(ValueError, match="api_key is required"): + Sdk(is_production=False) + + +def test_sdk_uses_credential_resolver_with_custom_transport() -> None: + """Wire resolver + transport together and use resolved auth header.""" + transport = _CaptureTransport() + resolver = ScopedCredentialResolver(default_api_key=DEFAULT_API_KEY) + + sdk = Sdk( + is_production=False, + transport=transport, + credential_resolver=resolver, + ) + + sdk.get_client().create_get_request("json/orders") + + assert sdk.get_client().transport is transport + assert transport.headers["Authorization"] == f"Bearer {DEFAULT_API_KEY}"