d365fo-client 0.2.2__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.
- d365fo_client/auth.py +48 -9
- d365fo_client/client.py +40 -20
- d365fo_client/credential_sources.py +431 -0
- d365fo_client/mcp/client_manager.py +8 -0
- d365fo_client/mcp/main.py +39 -17
- d365fo_client/mcp/server.py +69 -22
- d365fo_client/mcp/tools/__init__.py +2 -0
- d365fo_client/mcp/tools/profile_tools.py +261 -2
- d365fo_client/mcp/tools/sync_tools.py +503 -0
- d365fo_client/metadata_api.py +67 -0
- d365fo_client/metadata_v2/cache_v2.py +11 -9
- d365fo_client/metadata_v2/global_version_manager.py +2 -4
- d365fo_client/metadata_v2/sync_manager_v2.py +1 -1
- d365fo_client/metadata_v2/sync_session_manager.py +1043 -0
- d365fo_client/models.py +22 -3
- d365fo_client/profile_manager.py +7 -1
- d365fo_client/profiles.py +28 -1
- d365fo_client/sync_models.py +181 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.3.dist-info}/METADATA +48 -17
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.3.dist-info}/RECORD +24 -20
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.3.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.3.dist-info}/entry_points.txt +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.2.dist-info → d365fo_client-0.2.3.dist-info}/top_level.txt +0 -0
d365fo_client/auth.py
CHANGED
@@ -5,6 +5,7 @@ from typing import Optional
|
|
5
5
|
|
6
6
|
from azure.identity import ClientSecretCredential, DefaultAzureCredential
|
7
7
|
|
8
|
+
from .credential_sources import CredentialManager, CredentialSource
|
8
9
|
from .models import FOClientConfig
|
9
10
|
|
10
11
|
|
@@ -20,25 +21,46 @@ class AuthenticationManager:
|
|
20
21
|
self.config = config
|
21
22
|
self._token = None
|
22
23
|
self._token_expires = None
|
23
|
-
self.
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
24
|
+
self._credential_manager = CredentialManager()
|
25
|
+
self.credential = None # Will be set by _setup_credentials
|
26
|
+
|
27
|
+
async def _setup_credentials(self):
|
28
|
+
"""Setup authentication credentials with support for credential sources"""
|
29
|
+
|
30
|
+
# Check if credential source is specified in config
|
31
|
+
credential_source = getattr(self.config, 'credential_source', None)
|
32
|
+
|
33
|
+
if credential_source is not None:
|
34
|
+
# Use credential source to get credentials
|
35
|
+
try:
|
36
|
+
client_id, client_secret, tenant_id = await self._credential_manager.get_credentials(credential_source)
|
37
|
+
self.credential = ClientSecretCredential(
|
38
|
+
tenant_id=tenant_id,
|
39
|
+
client_id=client_id,
|
40
|
+
client_secret=client_secret,
|
41
|
+
)
|
42
|
+
return
|
43
|
+
except Exception as e:
|
44
|
+
raise ValueError(f"Failed to setup credentials from source: {e}")
|
45
|
+
|
46
|
+
|
47
|
+
# Fallback to existing logic for backward compatibility
|
48
|
+
|
49
|
+
if (
|
30
50
|
self.config.client_id
|
31
51
|
and self.config.client_secret
|
32
52
|
and self.config.tenant_id
|
33
53
|
):
|
34
|
-
|
54
|
+
self.credential = ClientSecretCredential(
|
35
55
|
tenant_id=self.config.tenant_id,
|
36
56
|
client_id=self.config.client_id,
|
37
57
|
client_secret=self.config.client_secret,
|
38
58
|
)
|
59
|
+
elif self.config.use_default_credentials:
|
60
|
+
self.credential = DefaultAzureCredential()
|
39
61
|
else:
|
40
62
|
raise ValueError(
|
41
|
-
"Must provide either use_default_credentials=True
|
63
|
+
"Must provide either use_default_credentials=True, client credentials, or credential_source"
|
42
64
|
)
|
43
65
|
|
44
66
|
async def get_token(self) -> str:
|
@@ -51,6 +73,10 @@ class AuthenticationManager:
|
|
51
73
|
if self._is_localhost():
|
52
74
|
return "mock-token-for-localhost"
|
53
75
|
|
76
|
+
# Initialize credentials if not already set
|
77
|
+
if self.credential is None:
|
78
|
+
await self._setup_credentials()
|
79
|
+
|
54
80
|
if (
|
55
81
|
self._token
|
56
82
|
and self._token_expires
|
@@ -91,3 +117,16 @@ class AuthenticationManager:
|
|
91
117
|
"""Invalidate cached token to force refresh"""
|
92
118
|
self._token = None
|
93
119
|
self._token_expires = None
|
120
|
+
|
121
|
+
async def invalidate_credentials(self):
|
122
|
+
"""Invalidate cached credentials and token to force full refresh"""
|
123
|
+
self.invalidate_token()
|
124
|
+
self.credential = None
|
125
|
+
if hasattr(self, '_credential_manager'):
|
126
|
+
self._credential_manager.clear_cache()
|
127
|
+
|
128
|
+
def get_credential_cache_stats(self) -> dict:
|
129
|
+
"""Get credential cache statistics for debugging"""
|
130
|
+
if hasattr(self, '_credential_manager'):
|
131
|
+
return self._credential_manager.get_cache_stats()
|
132
|
+
return {"total_cached": 0, "expired": 0, "active": 0}
|
d365fo_client/client.py
CHANGED
@@ -11,6 +11,7 @@ from .exceptions import FOClientError
|
|
11
11
|
from .labels import LabelOperations, resolve_labels_generic
|
12
12
|
from .metadata_api import MetadataAPIOperations
|
13
13
|
from .metadata_v2 import MetadataCacheV2, SmartSyncManagerV2
|
14
|
+
from .metadata_v2.sync_session_manager import SyncSessionManager
|
14
15
|
from .models import (
|
15
16
|
ActionInfo,
|
16
17
|
DataEntityInfo,
|
@@ -58,6 +59,7 @@ class FOClient:
|
|
58
59
|
# Initialize new metadata cache and sync components
|
59
60
|
self.metadata_cache = None
|
60
61
|
self.sync_manager = None
|
62
|
+
self._sync_session_manager = None
|
61
63
|
self._metadata_initialized = False
|
62
64
|
self._background_sync_task = None
|
63
65
|
|
@@ -83,6 +85,10 @@ class FOClient:
|
|
83
85
|
|
84
86
|
await self.session_manager.close()
|
85
87
|
|
88
|
+
|
89
|
+
async def initialize_metadata(self):
|
90
|
+
await self._ensure_metadata_initialized()
|
91
|
+
|
86
92
|
async def _ensure_metadata_initialized(self):
|
87
93
|
"""Ensure metadata cache and sync manager are initialized"""
|
88
94
|
if not self._metadata_initialized and self.config.enable_metadata_cache:
|
@@ -104,6 +110,9 @@ class FOClient:
|
|
104
110
|
self.metadata_cache, self.metadata_api_ops
|
105
111
|
)
|
106
112
|
|
113
|
+
# Initialize sync message with session
|
114
|
+
self._sync_session_manager = SyncSessionManager(self.metadata_cache, self.metadata_api_ops)
|
115
|
+
|
107
116
|
self._metadata_initialized = True
|
108
117
|
self.logger.debug("Metadata cache v2 with label caching initialized")
|
109
118
|
|
@@ -144,18 +153,9 @@ class FOClient:
|
|
144
153
|
f"Starting background metadata sync for version {global_version_id}"
|
145
154
|
)
|
146
155
|
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
if result.success:
|
151
|
-
self.logger.info(
|
152
|
-
f"Background sync completed: "
|
153
|
-
f"{result.entity_count} entities, "
|
154
|
-
f"{result.enumeration_count} enumerations, "
|
155
|
-
f"{result.duration_ms:.2f}ms"
|
156
|
-
)
|
157
|
-
else:
|
158
|
-
self.logger.warning(f"Background sync failed: {result.error}")
|
156
|
+
|
157
|
+
self.sync_session_manager.start_sync_session(global_version_id=global_version_id,initiated_by="background_task")
|
158
|
+
|
159
159
|
|
160
160
|
except Exception as e:
|
161
161
|
self.logger.error(f"Background sync error: {e}")
|
@@ -172,6 +172,27 @@ class FOClient:
|
|
172
172
|
and not self._background_sync_task.done()
|
173
173
|
)
|
174
174
|
|
175
|
+
@property
|
176
|
+
def sync_session_manager(self) -> SyncSessionManager:
|
177
|
+
"""Get sync session manager (lazy initialization).
|
178
|
+
|
179
|
+
Returns:
|
180
|
+
SyncSessionManager instance for enhanced sync progress tracking
|
181
|
+
|
182
|
+
Raises:
|
183
|
+
RuntimeError: If metadata cache is not initialized
|
184
|
+
"""
|
185
|
+
if self._sync_session_manager is None:
|
186
|
+
if not self.metadata_cache:
|
187
|
+
raise RuntimeError("Metadata cache must be initialized before accessing sync session manager")
|
188
|
+
|
189
|
+
self._sync_session_manager = SyncSessionManager(
|
190
|
+
cache=self.metadata_cache,
|
191
|
+
metadata_api=self.metadata_api_ops
|
192
|
+
)
|
193
|
+
|
194
|
+
return self._sync_session_manager
|
195
|
+
|
175
196
|
async def _get_from_cache_first(
|
176
197
|
self,
|
177
198
|
cache_method,
|
@@ -687,17 +708,16 @@ class FOClient:
|
|
687
708
|
|
688
709
|
async def get_data_entities(
|
689
710
|
self, options: Optional[QueryOptions] = None
|
690
|
-
) ->
|
691
|
-
"""Get data entities
|
711
|
+
) -> Dict[str, Any]:
|
712
|
+
"""Get data entities from DataEntities metadata endpoint
|
692
713
|
|
693
714
|
Args:
|
694
|
-
options: OData query options
|
715
|
+
options: OData query options
|
695
716
|
|
696
717
|
Returns:
|
697
|
-
|
718
|
+
Response containing data entities
|
698
719
|
"""
|
699
|
-
|
700
|
-
return await self.metadata_api_ops.search_data_entities("") # Get all entities
|
720
|
+
return await self.metadata_api_ops.get_data_entities(options)
|
701
721
|
|
702
722
|
async def get_data_entities_raw(
|
703
723
|
self, options: Optional[QueryOptions] = None
|
@@ -934,7 +954,7 @@ class FOClient:
|
|
934
954
|
Returns:
|
935
955
|
Response containing public enumerations
|
936
956
|
"""
|
937
|
-
self._ensure_metadata_initialized()
|
957
|
+
await self._ensure_metadata_initialized()
|
938
958
|
|
939
959
|
return await self.metadata_api_ops.get_public_enumerations(options)
|
940
960
|
|
@@ -1188,7 +1208,7 @@ class FOClient:
|
|
1188
1208
|
"advanced_cache_enabled": True,
|
1189
1209
|
"cache_v2_enabled": True,
|
1190
1210
|
"cache_initialized": self._metadata_initialized,
|
1191
|
-
"sync_manager_available": self.sync_manager is not None,
|
1211
|
+
"sync_manager_available": self.sync_manager is not None or self._sync_session_manager is not None,
|
1192
1212
|
"background_sync_running": self._is_background_sync_running(),
|
1193
1213
|
"statistics": stats,
|
1194
1214
|
}
|
@@ -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
|