dynamics-client-gh 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dynamics_client/__init__.py +8 -0
- dynamics_client/base_client.py +8 -0
- dynamics_client/base_token_manager.py +23 -0
- dynamics_client/certificate_token_manager.py +144 -0
- dynamics_client/dataverse_client.py +103 -0
- dynamics_client/exceptions.py +7 -0
- dynamics_client/py.typed +0 -0
- dynamics_client/token_manager.py +65 -0
- dynamics_client_gh-0.1.2.dist-info/METADATA +51 -0
- dynamics_client_gh-0.1.2.dist-info/RECORD +11 -0
- dynamics_client_gh-0.1.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .base_client import BaseClient
|
|
2
|
+
from .dataverse_client import DataverseClient
|
|
3
|
+
from .token_manager import TokenManager
|
|
4
|
+
from .certificate_token_manager import CertificateTokenManager
|
|
5
|
+
from .exceptions import TokenAcquisitionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = ["BaseClient", "DataverseClient", "TokenManager", "TokenAcquisitionError", "CertificateTokenManager"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseTokenManager(ABC):
|
|
6
|
+
def __init__(self, *args, **kwargs):
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
def get_headers(self) -> dict:
|
|
10
|
+
return {
|
|
11
|
+
"Authorization": f"Bearer {self.get_token()}",
|
|
12
|
+
"Accept": "application/json",
|
|
13
|
+
"OData-MaxVersion": "4.0",
|
|
14
|
+
"OData-Version": "4.0",
|
|
15
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def get_token(self) -> str:
|
|
19
|
+
with self._lock:
|
|
20
|
+
if self._access_token and time.time() < self._expires_at - 60:
|
|
21
|
+
return self._access_token
|
|
22
|
+
self._fetch_token()
|
|
23
|
+
return self._access_token
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError, ResourceNotFoundError
|
|
6
|
+
from azure.identity import CertificateCredential, DefaultAzureCredential
|
|
7
|
+
from azure.keyvault.secrets import SecretClient
|
|
8
|
+
from .base_token_manager import BaseTokenManager
|
|
9
|
+
from .exceptions import CertificateAuthError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CertificateTokenManager(BaseTokenManager):
|
|
13
|
+
"""
|
|
14
|
+
TokenManager-compatible class that authenticates to Dataverse using a
|
|
15
|
+
certificate pulled from Azure Key Vault, instead of a client secret.
|
|
16
|
+
|
|
17
|
+
The certificate's private key material is stored in Key Vault as the
|
|
18
|
+
secret linked to the certificate (standard Key Vault behavior), so it
|
|
19
|
+
is fetched via SecretClient and handed to CertificateCredential.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
tenant_id: str,
|
|
25
|
+
client_id: str,
|
|
26
|
+
dataverse_url: str,
|
|
27
|
+
key_vault_url: str,
|
|
28
|
+
certificate_secret_name: str,
|
|
29
|
+
managed_identity_client_id: str = None,
|
|
30
|
+
):
|
|
31
|
+
for name, value in (
|
|
32
|
+
("tenant_id", tenant_id),
|
|
33
|
+
("client_id", client_id),
|
|
34
|
+
("dataverse_url", dataverse_url),
|
|
35
|
+
("key_vault_url", key_vault_url),
|
|
36
|
+
("certificate_secret_name", certificate_secret_name),
|
|
37
|
+
):
|
|
38
|
+
if not value:
|
|
39
|
+
raise ValueError(f"'{name}' is required and cannot be empty")
|
|
40
|
+
|
|
41
|
+
self.tenant_id = tenant_id
|
|
42
|
+
self.client_id = client_id
|
|
43
|
+
self.dataverse_url = dataverse_url.rstrip("/")
|
|
44
|
+
self.key_vault_url = key_vault_url
|
|
45
|
+
self.certificate_secret_name = certificate_secret_name
|
|
46
|
+
self.managed_identity_client_id = managed_identity_client_id
|
|
47
|
+
|
|
48
|
+
self._access_token = None
|
|
49
|
+
self._expires_at = 0
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self._credential = None # lazily built CertificateCredential
|
|
52
|
+
|
|
53
|
+
def _fetch_certificate_secret(self):
|
|
54
|
+
kv_credential_kwargs = {}
|
|
55
|
+
if self.managed_identity_client_id:
|
|
56
|
+
kv_credential_kwargs["managed_identity_client_id"] = self.managed_identity_client_id
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
# DefaultAzureCredential uses the Function App's managed identity in
|
|
60
|
+
# Azure, and falls back to local dev credentials (e.g. Azure CLI)
|
|
61
|
+
# when running locally.
|
|
62
|
+
key_vault_credential = DefaultAzureCredential(**kv_credential_kwargs)
|
|
63
|
+
secret_client = SecretClient(vault_url=self.key_vault_url, credential=key_vault_credential)
|
|
64
|
+
return secret_client.get_secret(self.certificate_secret_name)
|
|
65
|
+
except ResourceNotFoundError as e:
|
|
66
|
+
raise CertificateAuthError(
|
|
67
|
+
f"Certificate secret '{self.certificate_secret_name}' was not found in Key Vault "
|
|
68
|
+
f"'{self.key_vault_url}': {e}"
|
|
69
|
+
) from e
|
|
70
|
+
except ClientAuthenticationError as e:
|
|
71
|
+
raise CertificateAuthError(
|
|
72
|
+
f"Failed to authenticate to Key Vault '{self.key_vault_url}' using the managed "
|
|
73
|
+
f"identity/local credential: {e}"
|
|
74
|
+
) from e
|
|
75
|
+
except HttpResponseError as e:
|
|
76
|
+
raise CertificateAuthError(
|
|
77
|
+
f"Key Vault request for secret '{self.certificate_secret_name}' failed: {e}"
|
|
78
|
+
) from e
|
|
79
|
+
except CertificateAuthError:
|
|
80
|
+
raise
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise CertificateAuthError(
|
|
83
|
+
f"Unexpected error retrieving certificate secret '{self.certificate_secret_name}' "
|
|
84
|
+
f"from Key Vault '{self.key_vault_url}': {e}"
|
|
85
|
+
) from e
|
|
86
|
+
|
|
87
|
+
def _load_certificate_credential(self) -> CertificateCredential:
|
|
88
|
+
certificate_secret = self._fetch_certificate_secret()
|
|
89
|
+
|
|
90
|
+
if not certificate_secret or not certificate_secret.value:
|
|
91
|
+
raise CertificateAuthError(
|
|
92
|
+
f"Certificate secret '{self.certificate_secret_name}' in Key Vault "
|
|
93
|
+
f"'{self.key_vault_url}' has no value"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
pfx_bytes = base64.b64decode(certificate_secret.value, validate=True)
|
|
98
|
+
except (binascii.Error, ValueError) as e:
|
|
99
|
+
raise CertificateAuthError(
|
|
100
|
+
f"Certificate secret '{self.certificate_secret_name}' is not valid base64-encoded "
|
|
101
|
+
f"PFX data: {e}"
|
|
102
|
+
) from e
|
|
103
|
+
|
|
104
|
+
if not pfx_bytes:
|
|
105
|
+
raise CertificateAuthError(
|
|
106
|
+
f"Certificate secret '{self.certificate_secret_name}' decoded to empty PFX data"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
return CertificateCredential(
|
|
111
|
+
tenant_id=self.tenant_id,
|
|
112
|
+
client_id=self.client_id,
|
|
113
|
+
certificate_data=pfx_bytes,
|
|
114
|
+
)
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
raise CertificateAuthError(
|
|
117
|
+
f"Certificate secret '{self.certificate_secret_name}' could not be loaded as a valid "
|
|
118
|
+
f"certificate/private key: {e}"
|
|
119
|
+
) from e
|
|
120
|
+
|
|
121
|
+
def _fetch_token(self):
|
|
122
|
+
try:
|
|
123
|
+
if self._credential is None:
|
|
124
|
+
self._credential = self._load_certificate_credential()
|
|
125
|
+
token = self._credential.get_token(f"{self.dataverse_url}/.default")
|
|
126
|
+
except CertificateAuthError:
|
|
127
|
+
# Reset so a subsequent call can retry credential creation instead of
|
|
128
|
+
# being stuck with a half-initialized state.
|
|
129
|
+
self._credential = None
|
|
130
|
+
raise
|
|
131
|
+
except ClientAuthenticationError as e:
|
|
132
|
+
self._credential = None
|
|
133
|
+
raise CertificateAuthError(
|
|
134
|
+
f"Failed to acquire a Dataverse access token for '{self.dataverse_url}' using the "
|
|
135
|
+
f"certificate credential: {e}"
|
|
136
|
+
) from e
|
|
137
|
+
except Exception as e:
|
|
138
|
+
self._credential = None
|
|
139
|
+
raise CertificateAuthError(
|
|
140
|
+
f"Unexpected error acquiring Dataverse access token for '{self.dataverse_url}': {e}"
|
|
141
|
+
) from e
|
|
142
|
+
|
|
143
|
+
self._access_token = token.token
|
|
144
|
+
self._expires_at = token.expires_on
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# dynamics_auth/dataverse_client.py
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
from .token_manager import TokenManager
|
|
5
|
+
from .certificate_token_manager import CertificateTokenManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataverseError(Exception):
|
|
9
|
+
"""Raised when a Dataverse API call fails."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DataverseClient:
|
|
14
|
+
def __init__(self, token_manager: TokenManager, base_url: str, api_version: str = "v9.2", timeout: int = 30):
|
|
15
|
+
self.token_manager = token_manager
|
|
16
|
+
self.base_url = base_url.rstrip("/")
|
|
17
|
+
self.api_version = api_version
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def with_certificate_auth(
|
|
22
|
+
cls,
|
|
23
|
+
tenant_id: str,
|
|
24
|
+
client_id: str,
|
|
25
|
+
base_url: str,
|
|
26
|
+
key_vault_url: str,
|
|
27
|
+
certificate_secret_name: str,
|
|
28
|
+
managed_identity_client_id: str = None,
|
|
29
|
+
api_version: str = "v9.2",
|
|
30
|
+
timeout: int = 30,
|
|
31
|
+
) -> "DataverseClient":
|
|
32
|
+
"""
|
|
33
|
+
Convenience constructor that wires up certificate-based auth via
|
|
34
|
+
Azure Key Vault instead of the existing secret-based TokenManager.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
client = DataverseClient.with_certificate_auth(
|
|
38
|
+
tenant_id=os.environ["ENTRA_TENANT_ID"],
|
|
39
|
+
client_id=os.environ["DATAVERSE_CLIENT_ID"],
|
|
40
|
+
base_url=os.environ["DATAVERSE_URL"],
|
|
41
|
+
key_vault_url=os.environ["KEY_VAULT_URL"],
|
|
42
|
+
certificate_secret_name=os.environ["DATAVERSE_CERTIFICATE_SECRET_NAME"],
|
|
43
|
+
)
|
|
44
|
+
"""
|
|
45
|
+
token_manager = CertificateTokenManager(
|
|
46
|
+
tenant_id=tenant_id,
|
|
47
|
+
client_id=client_id,
|
|
48
|
+
dataverse_url=base_url,
|
|
49
|
+
key_vault_url=key_vault_url,
|
|
50
|
+
certificate_secret_name=certificate_secret_name,
|
|
51
|
+
managed_identity_client_id=managed_identity_client_id,
|
|
52
|
+
)
|
|
53
|
+
return cls(token_manager=token_manager, base_url=base_url, api_version=api_version, timeout=timeout)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def _api_url(self) -> str:
|
|
57
|
+
return f"{self.base_url}/api/data/{self.api_version}"
|
|
58
|
+
|
|
59
|
+
def _headers(self, extra: dict = None) -> dict:
|
|
60
|
+
headers = self.token_manager.get_headers()
|
|
61
|
+
if extra:
|
|
62
|
+
headers.update(extra)
|
|
63
|
+
return headers
|
|
64
|
+
|
|
65
|
+
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
66
|
+
url = f"{self._api_url}/{path.lstrip('/')}"
|
|
67
|
+
headers = self._headers(kwargs.pop("headers", None))
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
resp = requests.request(method, url, headers=headers, timeout=self.timeout, **kwargs)
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
except requests.RequestException as e:
|
|
73
|
+
raise DataverseError(f"{method} {url} failed: {e}") from e
|
|
74
|
+
return resp
|
|
75
|
+
|
|
76
|
+
# --- generic CRUD ---
|
|
77
|
+
|
|
78
|
+
def get(self, entity_set: str, entity_id: str = None, query: str = "") -> dict:
|
|
79
|
+
path = f"{entity_set}({entity_id})" if entity_id else entity_set
|
|
80
|
+
if query:
|
|
81
|
+
path += f"?{query}"
|
|
82
|
+
resp = self._request("GET", path)
|
|
83
|
+
return resp.json()
|
|
84
|
+
|
|
85
|
+
def get_list(self, entity_set: str, query: str = "") -> list:
|
|
86
|
+
data = self.get(entity_set, query=query)
|
|
87
|
+
return data.get("value", [])
|
|
88
|
+
|
|
89
|
+
def create(self, entity_set: str, payload: dict) -> str:
|
|
90
|
+
resp = self._request("POST", entity_set, json=payload)
|
|
91
|
+
# Dataverse returns the new record's URL in this header
|
|
92
|
+
return resp.headers.get("OData-EntityId")
|
|
93
|
+
|
|
94
|
+
def update(self, entity_set: str, entity_id: str, payload: dict) -> None:
|
|
95
|
+
self._request(
|
|
96
|
+
"PATCH",
|
|
97
|
+
f"{entity_set}({entity_id})",
|
|
98
|
+
json=payload,
|
|
99
|
+
headers={"If-Match": "*"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def delete(self, entity_set: str, entity_id: str) -> None:
|
|
103
|
+
self._request("DELETE", f"{entity_set}({entity_id})")
|
dynamics_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
from .exceptions import TokenAcquisitionError
|
|
5
|
+
from .base_token_manager import BaseTokenManager
|
|
6
|
+
|
|
7
|
+
class TokenManager(BaseTokenManager):
|
|
8
|
+
def __init__(self, tenant_id: str, client_id: str, client_secret: str, resource: str, timeout: int = 10):
|
|
9
|
+
self.tenant_id = tenant_id
|
|
10
|
+
self.client_id = client_id
|
|
11
|
+
self.client_secret = client_secret
|
|
12
|
+
self.resource = resource
|
|
13
|
+
self.timeout = timeout
|
|
14
|
+
|
|
15
|
+
self._access_token = None
|
|
16
|
+
self._expires_at = 0
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def _fetch_token(self):
|
|
20
|
+
try:
|
|
21
|
+
resp = requests.post(
|
|
22
|
+
f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/token",
|
|
23
|
+
data={
|
|
24
|
+
"grant_type": "client_credentials",
|
|
25
|
+
"client_id": self.client_id,
|
|
26
|
+
"client_secret": self.client_secret,
|
|
27
|
+
"resource": self.resource,
|
|
28
|
+
},
|
|
29
|
+
timeout=self.timeout,
|
|
30
|
+
)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
except requests.RequestException as e:
|
|
33
|
+
raise TokenAcquisitionError(f"Failed to acquire token: {e}") from e
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
data = resp.json()
|
|
37
|
+
except ValueError as e:
|
|
38
|
+
raise TokenAcquisitionError(f"Token endpoint returned a non-JSON response: {e}") from e
|
|
39
|
+
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
raise TokenAcquisitionError(
|
|
42
|
+
f"Token endpoint returned an unexpected payload type: {type(data).__name__}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
access_token = data.get("access_token")
|
|
46
|
+
expires_on = data.get("expires_on")
|
|
47
|
+
|
|
48
|
+
if not access_token:
|
|
49
|
+
raise TokenAcquisitionError(
|
|
50
|
+
f"Token response is missing 'access_token': {data}"
|
|
51
|
+
)
|
|
52
|
+
if expires_on is None:
|
|
53
|
+
raise TokenAcquisitionError(
|
|
54
|
+
f"Token response is missing 'expires_on': {data}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
expires_on = int(expires_on)
|
|
59
|
+
except (TypeError, ValueError) as e:
|
|
60
|
+
raise TokenAcquisitionError(
|
|
61
|
+
f"Token response has a non-integer 'expires_on' value ({expires_on!r}): {e}"
|
|
62
|
+
) from e
|
|
63
|
+
|
|
64
|
+
self._access_token = access_token
|
|
65
|
+
self._expires_at = expires_on
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dynamics-client-gh
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Client library to easaly work within the ms dynamics universe
|
|
5
|
+
Author-email: PommereningMartin <m.pommerening@gruenhorn.de>, MichaelWehling <m.wehling@gruenhorn.de>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: azure-identity
|
|
8
|
+
Requires-Dist: azure-keyvault-secrets
|
|
9
|
+
Requires-Dist: pytest>=9.1.1
|
|
10
|
+
Requires-Dist: requests>=2.34.2
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# dynamics-client-gh
|
|
14
|
+
|
|
15
|
+
Simple, thread-safe OAuth2 client-credentials authentication and a lightweight Dataverse (Microsoft Dynamics 365) OData client for Python.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- `TokenManager` — acquires and caches Azure AD access tokens automatically.
|
|
20
|
+
- `DataverseClient` — thin wrapper around the Dataverse Web API with `get`, `get_list`, `create`, `update`, `delete`.
|
|
21
|
+
- Thread-safe, minimal dependencies (`requests` only).
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
pip install dynamics-client-pukna
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python 3.8+
|
|
30
|
+
- Azure AD App Registration with a client secret
|
|
31
|
+
- A Dataverse Application User linked to that app, with a security role assigned
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
from dynamics_client import TokenManager
|
|
36
|
+
from dynamics_client.dataverse_client import DataverseClient
|
|
37
|
+
|
|
38
|
+
token_manager = TokenManager(
|
|
39
|
+
tenant_id="...", client_id="...", client_secret="...",
|
|
40
|
+
resource="https://yourorg.crm.dynamics.com",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
client = DataverseClient(token_manager=token_manager, base_url="https://yourorg.crm.dynamics.com")
|
|
44
|
+
|
|
45
|
+
accounts = client.get_list("accounts", query="$select=name&$top=5")
|
|
46
|
+
|
|
47
|
+
See the [full documentation](https://github.com/yourname/dynamics-auth#readme) for the API reference, error handling, and building custom entity services.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
dynamics_client/__init__.py,sha256=6R3nE70mjkZcZ0WIVVxc0ITt1cp-eha_Nzg1SG0g9zQ,351
|
|
2
|
+
dynamics_client/base_client.py,sha256=fGWoJM7mJg5mM_Jhjj1PFQqcq-nqzFKsxMA7uHCsWuQ,149
|
|
3
|
+
dynamics_client/base_token_manager.py,sha256=sPWOGUaPb5lFMgqjSyULjb21OfFHAiXr6IQrXY6u1vo,684
|
|
4
|
+
dynamics_client/certificate_token_manager.py,sha256=8wRQ70rqZ6foFGCqEB86ZG6g2CBzZVhjHjQAQExL7QY,6137
|
|
5
|
+
dynamics_client/dataverse_client.py,sha256=a5maUfv-dx04rrnct3CuaHetB9GVNNcUQ2SryFhcmeI,3837
|
|
6
|
+
dynamics_client/exceptions.py,sha256=uMy9PnP2mCiN5ktO40RQL5G4_mOdULNQEFjHTfFtpWE,245
|
|
7
|
+
dynamics_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
dynamics_client/token_manager.py,sha256=L-Z0VPK_ZKgZ7nCJ8YyMBkzCzj3RC09tWJEzWTIQwiQ,2345
|
|
9
|
+
dynamics_client_gh-0.1.2.dist-info/METADATA,sha256=cIy0xs8FhqITYNXbHkL1PjSU9OH4e3g00l14hWPstoQ,1665
|
|
10
|
+
dynamics_client_gh-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
dynamics_client_gh-0.1.2.dist-info/RECORD,,
|