d365fo-client 0.2.1__py3-none-any.whl → 0.2.3__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.
@@ -0,0 +1,431 @@
1
+ """Credential source management for D365 F&O client authentication."""
2
+
3
+ import logging
4
+ import os
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta
8
+ from typing import Any, Dict, Optional, Tuple, Union
9
+
10
+ from azure.identity import ClientSecretCredential, DefaultAzureCredential
11
+ from azure.keyvault.secrets import SecretClient
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class CredentialSource:
18
+ """Base credential source configuration."""
19
+
20
+ source_type: str # "environment", "keyvault", "file", etc.
21
+
22
+ def to_dict(self) -> Dict[str, str]:
23
+ """Convert to dictionary for serialization."""
24
+ return {"source_type": self.source_type}
25
+
26
+ @classmethod
27
+ def from_dict(cls, data: Dict[str, Any]) -> "CredentialSource":
28
+ """Create CredentialSource from dictionary."""
29
+ source_type = data.get("source_type")
30
+
31
+ if source_type == "environment":
32
+ return EnvironmentCredentialSource(
33
+ client_id_var=data.get("client_id_var", "D365FO_CLIENT_ID"),
34
+ client_secret_var=data.get("client_secret_var", "D365FO_CLIENT_SECRET"),
35
+ tenant_id_var=data.get("tenant_id_var", "D365FO_TENANT_ID")
36
+ )
37
+ elif source_type == "keyvault":
38
+ return KeyVaultCredentialSource(
39
+ vault_url=data.get("vault_url", ""),
40
+ client_id_secret_name=data.get("client_id_secret_name", "D365FO_CLIENT_ID"),
41
+ client_secret_secret_name=data.get("client_secret_secret_name", "D365FO_CLIENT_SECRET"),
42
+ tenant_id_secret_name=data.get("tenant_id_secret_name", "D365FO_TENANT_ID"),
43
+ keyvault_auth_mode=data.get("keyvault_auth_mode", "default"),
44
+ keyvault_client_id=data.get("keyvault_client_id"),
45
+ keyvault_client_secret=data.get("keyvault_client_secret"),
46
+ keyvault_tenant_id=data.get("keyvault_tenant_id")
47
+ )
48
+ else:
49
+ raise ValueError(f"Unknown credential source type: {source_type}")
50
+
51
+
52
+ @dataclass
53
+ class EnvironmentCredentialSource(CredentialSource):
54
+ """Environment variable credential source."""
55
+
56
+ client_id_var: str = "D365FO_CLIENT_ID"
57
+ client_secret_var: str = "D365FO_CLIENT_SECRET"
58
+ tenant_id_var: str = "D365FO_TENANT_ID"
59
+
60
+ def __init__(self, client_id_var: str = "D365FO_CLIENT_ID",
61
+ client_secret_var: str = "D365FO_CLIENT_SECRET",
62
+ tenant_id_var: str = "D365FO_TENANT_ID"):
63
+ """Initialize environment credential source."""
64
+ super().__init__(source_type="environment")
65
+ self.client_id_var = client_id_var
66
+ self.client_secret_var = client_secret_var
67
+ self.tenant_id_var = tenant_id_var
68
+
69
+ def to_dict(self) -> Dict[str, str]:
70
+ """Convert to dictionary for serialization."""
71
+ return {
72
+ "source_type": self.source_type,
73
+ "client_id_var": self.client_id_var,
74
+ "client_secret_var": self.client_secret_var,
75
+ "tenant_id_var": self.tenant_id_var,
76
+ }
77
+
78
+
79
+ @dataclass
80
+ class KeyVaultCredentialSource(CredentialSource):
81
+ """Azure Key Vault credential source."""
82
+
83
+ vault_url: str
84
+ client_id_secret_name: str
85
+ client_secret_secret_name: str
86
+ tenant_id_secret_name: str
87
+ # Authentication to Key Vault itself
88
+ keyvault_auth_mode: str = "default" # "default" or "client_secret"
89
+ keyvault_client_id: Optional[str] = None
90
+ keyvault_client_secret: Optional[str] = None
91
+ keyvault_tenant_id: Optional[str] = None
92
+
93
+ def __init__(self, vault_url: str, client_id_secret_name: str,
94
+ client_secret_secret_name: str, tenant_id_secret_name: str,
95
+ keyvault_auth_mode: str = "default",
96
+ keyvault_client_id: Optional[str] = None,
97
+ keyvault_client_secret: Optional[str] = None,
98
+ keyvault_tenant_id: Optional[str] = None):
99
+ """Initialize Key Vault credential source."""
100
+ super().__init__(source_type="keyvault")
101
+ self.vault_url = vault_url
102
+ self.client_id_secret_name = client_id_secret_name
103
+ self.client_secret_secret_name = client_secret_secret_name
104
+ self.tenant_id_secret_name = tenant_id_secret_name
105
+ self.keyvault_auth_mode = keyvault_auth_mode
106
+ self.keyvault_client_id = keyvault_client_id
107
+ self.keyvault_client_secret = keyvault_client_secret
108
+ self.keyvault_tenant_id = keyvault_tenant_id
109
+
110
+ def to_dict(self) -> Dict[str, str]:
111
+ """Convert to dictionary for serialization."""
112
+ result = {
113
+ "source_type": self.source_type,
114
+ "vault_url": self.vault_url,
115
+ "client_id_secret_name": self.client_id_secret_name,
116
+ "client_secret_secret_name": self.client_secret_secret_name,
117
+ "tenant_id_secret_name": self.tenant_id_secret_name,
118
+ "keyvault_auth_mode": self.keyvault_auth_mode,
119
+ }
120
+
121
+ # Only include Key Vault auth credentials if using client_secret mode
122
+ if self.keyvault_auth_mode == "client_secret":
123
+ result.update({
124
+ "keyvault_client_id": self.keyvault_client_id or "",
125
+ "keyvault_client_secret": self.keyvault_client_secret or "",
126
+ "keyvault_tenant_id": self.keyvault_tenant_id or "",
127
+ })
128
+
129
+ return result
130
+
131
+
132
+ @dataclass
133
+ class CachedCredentials:
134
+ """Cached credential information with expiry."""
135
+
136
+ client_id: str
137
+ client_secret: str
138
+ tenant_id: str
139
+ expires_at: datetime
140
+ source_hash: str # Hash of source configuration for invalidation
141
+
142
+ def is_expired(self) -> bool:
143
+ """Check if credentials are expired."""
144
+ return datetime.now() >= self.expires_at
145
+
146
+ def is_valid_for_source(self, source_hash: str) -> bool:
147
+ """Check if cached credentials are valid for the given source."""
148
+ return not self.is_expired() and self.source_hash == source_hash
149
+
150
+
151
+ class CredentialProvider(ABC):
152
+ """Base class for credential providers."""
153
+
154
+ @abstractmethod
155
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
156
+ """
157
+ Retrieve credentials from the source.
158
+
159
+ Args:
160
+ source: Credential source configuration
161
+
162
+ Returns:
163
+ Tuple of (client_id, client_secret, tenant_id)
164
+
165
+ Raises:
166
+ ValueError: If credentials cannot be retrieved or are invalid
167
+ Exception: For other retrieval errors
168
+ """
169
+ pass
170
+
171
+ def _get_source_hash(self, source: CredentialSource) -> str:
172
+ """Generate a hash for the credential source configuration."""
173
+ import hashlib
174
+ source_data = str(source.to_dict())
175
+ return hashlib.sha256(source_data.encode()).hexdigest()[:16]
176
+
177
+
178
+ class EnvironmentCredentialProvider(CredentialProvider):
179
+ """Provides credentials from environment variables."""
180
+
181
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
182
+ """
183
+ Retrieve credentials from environment variables.
184
+
185
+ Args:
186
+ source: EnvironmentCredentialSource configuration
187
+
188
+ Returns:
189
+ Tuple of (client_id, client_secret, tenant_id)
190
+
191
+ Raises:
192
+ ValueError: If required environment variables are missing
193
+ """
194
+ if not isinstance(source, EnvironmentCredentialSource):
195
+ raise ValueError(f"Expected EnvironmentCredentialSource, got {type(source)}")
196
+
197
+ client_id = os.getenv(source.client_id_var)
198
+ client_secret = os.getenv(source.client_secret_var)
199
+ tenant_id = os.getenv(source.tenant_id_var)
200
+
201
+ missing_vars = []
202
+ if not client_id:
203
+ missing_vars.append(source.client_id_var)
204
+ if not client_secret:
205
+ missing_vars.append(source.client_secret_var)
206
+ if not tenant_id:
207
+ missing_vars.append(source.tenant_id_var)
208
+
209
+ if missing_vars:
210
+ raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
211
+
212
+ logger.debug(f"Retrieved credentials from environment variables: {source.client_id_var}, {source.client_secret_var}, {source.tenant_id_var}")
213
+ return client_id, client_secret, tenant_id
214
+
215
+
216
+ class KeyVaultCredentialProvider(CredentialProvider):
217
+ """Provides credentials from Azure Key Vault."""
218
+
219
+ def __init__(self):
220
+ """Initialize Key Vault credential provider."""
221
+ self._secret_clients: Dict[str, SecretClient] = {}
222
+
223
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
224
+ """
225
+ Retrieve credentials from Azure Key Vault.
226
+
227
+ Args:
228
+ source: KeyVaultCredentialSource configuration
229
+
230
+ Returns:
231
+ Tuple of (client_id, client_secret, tenant_id)
232
+
233
+ Raises:
234
+ ValueError: If Key Vault access fails or secrets are missing
235
+ """
236
+ if not isinstance(source, KeyVaultCredentialSource):
237
+ raise ValueError(f"Expected KeyVaultCredentialSource, got {type(source)}")
238
+
239
+ try:
240
+ secret_client = self._get_secret_client(source)
241
+
242
+ # Retrieve secrets from Key Vault
243
+ client_id_secret = secret_client.get_secret(source.client_id_secret_name)
244
+ client_secret_secret = secret_client.get_secret(source.client_secret_secret_name)
245
+ tenant_id_secret = secret_client.get_secret(source.tenant_id_secret_name)
246
+
247
+ client_id = client_id_secret.value
248
+ client_secret = client_secret_secret.value
249
+ tenant_id = tenant_id_secret.value
250
+
251
+ if not all([client_id, client_secret, tenant_id]):
252
+ raise ValueError("One or more secrets retrieved from Key Vault are empty")
253
+
254
+ logger.debug(f"Retrieved credentials from Key Vault: {source.vault_url}")
255
+ return client_id, client_secret, tenant_id
256
+
257
+ except Exception as e:
258
+ logger.error(f"Failed to retrieve credentials from Key Vault {source.vault_url}: {e}")
259
+ raise ValueError(f"Key Vault credential retrieval failed: {e}")
260
+
261
+ def _get_secret_client(self, source: KeyVaultCredentialSource) -> SecretClient:
262
+ """
263
+ Get or create a SecretClient for the Key Vault.
264
+
265
+ Args:
266
+ source: KeyVaultCredentialSource configuration
267
+
268
+ Returns:
269
+ SecretClient instance
270
+ """
271
+ vault_url = source.vault_url
272
+
273
+ # Return cached client if available
274
+ if vault_url in self._secret_clients:
275
+ return self._secret_clients[vault_url]
276
+
277
+ # Create credential for Key Vault authentication
278
+ if source.keyvault_auth_mode == "default":
279
+ credential = DefaultAzureCredential()
280
+ logger.debug(f"Using default credentials for Key Vault authentication: {vault_url}")
281
+ elif source.keyvault_auth_mode == "client_secret":
282
+ if not all([source.keyvault_client_id, source.keyvault_client_secret, source.keyvault_tenant_id]):
283
+ raise ValueError("Key Vault client_secret authentication requires keyvault_client_id, keyvault_client_secret, and keyvault_tenant_id")
284
+
285
+ credential = ClientSecretCredential(
286
+ tenant_id=source.keyvault_tenant_id,
287
+ client_id=source.keyvault_client_id,
288
+ client_secret=source.keyvault_client_secret,
289
+ )
290
+ logger.debug(f"Using client secret credentials for Key Vault authentication: {vault_url}")
291
+ else:
292
+ raise ValueError(f"Invalid keyvault_auth_mode: {source.keyvault_auth_mode}. Must be 'default' or 'client_secret'")
293
+
294
+ # Create and cache the SecretClient
295
+ secret_client = SecretClient(vault_url=vault_url, credential=credential)
296
+ self._secret_clients[vault_url] = secret_client
297
+
298
+ return secret_client
299
+
300
+
301
+ class CredentialManager:
302
+ """
303
+ Manages credential retrieval from multiple sources with caching.
304
+
305
+ This class provides a unified interface for retrieving credentials from
306
+ various sources (environment variables, Key Vault, etc.) with automatic
307
+ caching and validation.
308
+ """
309
+
310
+ def __init__(self, cache_ttl_minutes: int = 30):
311
+ """
312
+ Initialize credential manager.
313
+
314
+ Args:
315
+ cache_ttl_minutes: Time-to-live for cached credentials in minutes
316
+ """
317
+ self.cache_ttl_minutes = cache_ttl_minutes
318
+ self._credential_cache: Dict[str, CachedCredentials] = {}
319
+ self._providers: Dict[str, CredentialProvider] = {
320
+ "environment": EnvironmentCredentialProvider(),
321
+ "keyvault": KeyVaultCredentialProvider(),
322
+ }
323
+
324
+ async def get_credentials(self, source: CredentialSource) -> Tuple[str, str, str]:
325
+ """
326
+ Retrieve credentials from the specified source with caching.
327
+
328
+ Args:
329
+ source: Credential source configuration
330
+
331
+ Returns:
332
+ Tuple of (client_id, client_secret, tenant_id)
333
+
334
+ Raises:
335
+ ValueError: If source type is unsupported or credentials cannot be retrieved
336
+ """
337
+ # Check cache first
338
+ source_hash = self._get_source_hash(source)
339
+ cache_key = f"{source.source_type}:{source_hash}"
340
+
341
+ if cache_key in self._credential_cache:
342
+ cached = self._credential_cache[cache_key]
343
+ if cached.is_valid_for_source(source_hash):
344
+ logger.debug(f"Using cached credentials for source type: {source.source_type}")
345
+ return cached.client_id, cached.client_secret, cached.tenant_id
346
+ else:
347
+ # Remove expired cache entry
348
+ del self._credential_cache[cache_key]
349
+ logger.debug(f"Expired cached credentials removed for source type: {source.source_type}")
350
+
351
+ # Retrieve from provider
352
+ provider = self._providers.get(source.source_type)
353
+ if not provider:
354
+ raise ValueError(f"Unsupported credential source type: {source.source_type}")
355
+
356
+ client_id, client_secret, tenant_id = await provider.get_credentials(source)
357
+
358
+ # Cache the credentials
359
+ expires_at = datetime.now() + timedelta(minutes=self.cache_ttl_minutes)
360
+ cached_credentials = CachedCredentials(
361
+ client_id=client_id,
362
+ client_secret=client_secret,
363
+ tenant_id=tenant_id,
364
+ expires_at=expires_at,
365
+ source_hash=source_hash,
366
+ )
367
+ self._credential_cache[cache_key] = cached_credentials
368
+
369
+ logger.debug(f"Retrieved and cached credentials from source type: {source.source_type}")
370
+ return client_id, client_secret, tenant_id
371
+
372
+ def _get_source_hash(self, source: CredentialSource) -> str:
373
+ """Generate a hash for the credential source configuration."""
374
+ import hashlib
375
+ source_data = str(source.to_dict())
376
+ return hashlib.sha256(source_data.encode()).hexdigest()[:16]
377
+
378
+ def clear_cache(self, source_type: Optional[str] = None) -> None:
379
+ """
380
+ Clear credential cache.
381
+
382
+ Args:
383
+ source_type: If specified, only clear cache for this source type.
384
+ If None, clear all cached credentials.
385
+ """
386
+ if source_type is None:
387
+ self._credential_cache.clear()
388
+ logger.debug("Cleared all cached credentials")
389
+ else:
390
+ keys_to_remove = [key for key in self._credential_cache.keys() if key.startswith(f"{source_type}:")]
391
+ for key in keys_to_remove:
392
+ del self._credential_cache[key]
393
+ logger.debug(f"Cleared cached credentials for source type: {source_type}")
394
+
395
+ def get_cache_stats(self) -> Dict[str, int]:
396
+ """
397
+ Get cache statistics.
398
+
399
+ Returns:
400
+ Dictionary with cache statistics
401
+ """
402
+ total_cached = len(self._credential_cache)
403
+ expired_count = sum(1 for cached in self._credential_cache.values() if cached.is_expired())
404
+
405
+ return {
406
+ "total_cached": total_cached,
407
+ "expired": expired_count,
408
+ "active": total_cached - expired_count,
409
+ }
410
+
411
+
412
+ def create_credential_source(source_type: str, **kwargs) -> CredentialSource:
413
+ """
414
+ Factory function to create credential source instances.
415
+
416
+ Args:
417
+ source_type: Type of credential source ("environment", "keyvault")
418
+ **kwargs: Source-specific configuration parameters
419
+
420
+ Returns:
421
+ CredentialSource instance
422
+
423
+ Raises:
424
+ ValueError: If source_type is unsupported
425
+ """
426
+ if source_type == "environment":
427
+ return EnvironmentCredentialSource(**kwargs)
428
+ elif source_type == "keyvault":
429
+ return KeyVaultCredentialSource(**kwargs)
430
+ else:
431
+ raise ValueError(f"Unsupported credential source type: {source_type}")
@@ -201,6 +201,14 @@ class D365FOClientManager:
201
201
  raise ValueError(
202
202
  f"Profile '{env_profile.name}' has no base_url configured"
203
203
  )
204
+
205
+ # Check if legacy config should override certain settings
206
+ default_config = self.config.get("default_environment", {})
207
+ if default_config and profile == "default":
208
+ # Override use_default_credentials if specified in legacy config
209
+ if "use_default_credentials" in default_config:
210
+ config.use_default_credentials = default_config["use_default_credentials"]
211
+
204
212
  return config
205
213
 
206
214
  # Fallback to legacy config-based profiles
d365fo_client/mcp/main.py CHANGED
@@ -43,28 +43,50 @@ def setup_logging(level: str = "INFO") -> None:
43
43
 
44
44
  def load_config() -> Dict[str, Any]:
45
45
  """Load configuration from environment and config files.
46
+
47
+ Handles three startup scenarios:
48
+ 1. No environment variables: Profile-only mode
49
+ 2. D365FO_BASE_URL only: Default auth mode
50
+ 3. Full variables: Client credentials mode
46
51
 
47
52
  Returns:
48
- Configuration dictionary
53
+ Configuration dictionary with startup_mode indicator
49
54
  """
50
55
  config = {}
51
-
52
- # Load from environment variables
53
- if base_url := os.getenv("D365FO_BASE_URL"):
56
+
57
+ # Get environment variables
58
+ base_url = os.getenv("D365FO_BASE_URL")
59
+ client_id = os.getenv("D365FO_CLIENT_ID")
60
+ client_secret = os.getenv("D365FO_CLIENT_SECRET")
61
+ tenant_id = os.getenv("D365FO_TENANT_ID")
62
+
63
+ # Determine startup mode based on available environment variables
64
+ if not base_url:
65
+ # Scenario 1: No environment variables - profile-only mode
66
+ config["startup_mode"] = "profile_only"
67
+ config["has_base_url"] = False
68
+ logging.info("Startup mode: profile-only (no D365FO_BASE_URL provided)")
69
+
70
+ elif base_url and not (client_id and client_secret and tenant_id):
71
+ # Scenario 2: Only base URL - default authentication
72
+ config["startup_mode"] = "default_auth"
73
+ config["has_base_url"] = True
54
74
  config.setdefault("default_environment", {})["base_url"] = base_url
55
-
56
- if client_id := os.getenv("AZURE_CLIENT_ID"):
57
- config.setdefault("default_environment", {})["client_id"] = client_id
58
- config["default_environment"]["use_default_credentials"] = False
59
-
60
- if client_secret := os.getenv("AZURE_CLIENT_SECRET"):
61
- config.setdefault("default_environment", {})["client_secret"] = client_secret
62
-
63
- if tenant_id := os.getenv("AZURE_TENANT_ID"):
64
- config.setdefault("default_environment", {})["tenant_id"] = tenant_id
65
-
66
- # Check if D365FO_BASE_URL is configured for startup behavior
67
- config["has_base_url"] = bool(os.getenv("D365FO_BASE_URL"))
75
+ config["default_environment"]["use_default_credentials"] = True
76
+ logging.info("Startup mode: default authentication (D365FO_BASE_URL provided)")
77
+
78
+ else:
79
+ # Scenario 3: Full credentials - client credentials authentication
80
+ config["startup_mode"] = "client_credentials"
81
+ config["has_base_url"] = True
82
+ config.setdefault("default_environment", {}).update({
83
+ "base_url": base_url,
84
+ "client_id": client_id,
85
+ "client_secret": client_secret,
86
+ "tenant_id": tenant_id,
87
+ "use_default_credentials": False
88
+ })
89
+ logging.info("Startup mode: client credentials (full D365FO environment variables provided)")
68
90
 
69
91
  return config
70
92
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from datetime import datetime
5
- from enum import Enum
5
+ from enum import StrEnum
6
6
  from typing import Any, Dict, List, Optional, Union
7
7
 
8
8
  # Resource Models
@@ -37,7 +37,7 @@ class EntityResourceContent:
37
37
  last_updated: Optional[str] = None
38
38
 
39
39
 
40
- class MetadataType(Enum):
40
+ class MetadataType(StrEnum):
41
41
  ENTITIES = "entities"
42
42
  ACTIONS = "actions"
43
43
  ENUMERATIONS = "enumerations"