miso-client 0.2.0__py3-none-any.whl → 0.4.0__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.
Potentially problematic release.
This version of miso-client might be problematic. Click here for more details.
- miso_client/__init__.py +21 -3
- miso_client/errors.py +22 -1
- miso_client/models/__init__.py +4 -0
- miso_client/models/error_response.py +41 -0
- miso_client/services/logger.py +7 -6
- miso_client/utils/data_masker.py +77 -5
- miso_client/utils/http_client.py +416 -218
- miso_client/utils/internal_http_client.py +471 -0
- miso_client/utils/sensitive_fields_loader.py +116 -0
- {miso_client-0.2.0.dist-info → miso_client-0.4.0.dist-info}/METADATA +129 -3
- miso_client-0.4.0.dist-info/RECORD +26 -0
- miso_client-0.2.0.dist-info/RECORD +0 -23
- {miso_client-0.2.0.dist-info → miso_client-0.4.0.dist-info}/WHEEL +0 -0
- {miso_client-0.2.0.dist-info → miso_client-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.2.0.dist-info → miso_client-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal HTTP client utility for controller communication.
|
|
3
|
+
|
|
4
|
+
This module provides the internal HTTP client implementation with automatic client
|
|
5
|
+
token management. This class is not meant to be used directly - use the public
|
|
6
|
+
HttpClient class instead which adds ISO 27001 compliant audit and debug logging.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Any, Dict, Literal, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..errors import AuthenticationError, ConnectionError, MisoClientError
|
|
16
|
+
from ..models.config import ClientTokenResponse, MisoClientConfig
|
|
17
|
+
from ..models.error_response import ErrorResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InternalHttpClient:
|
|
21
|
+
"""
|
|
22
|
+
Internal HTTP client for Miso Controller communication with automatic client token management.
|
|
23
|
+
|
|
24
|
+
This class contains the core HTTP functionality without logging.
|
|
25
|
+
It is wrapped by the public HttpClient class which adds audit and debug logging.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: MisoClientConfig):
|
|
29
|
+
"""
|
|
30
|
+
Initialize internal HTTP client with configuration.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
config: MisoClient configuration
|
|
34
|
+
"""
|
|
35
|
+
self.config = config
|
|
36
|
+
self.client: Optional[httpx.AsyncClient] = None
|
|
37
|
+
self.client_token: Optional[str] = None
|
|
38
|
+
self.token_expires_at: Optional[datetime] = None
|
|
39
|
+
self.token_refresh_lock = asyncio.Lock()
|
|
40
|
+
|
|
41
|
+
async def _initialize_client(self):
|
|
42
|
+
"""Initialize HTTP client if not already initialized."""
|
|
43
|
+
if self.client is None:
|
|
44
|
+
self.client = httpx.AsyncClient(
|
|
45
|
+
base_url=self.config.controller_url,
|
|
46
|
+
timeout=30.0,
|
|
47
|
+
headers={
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def _get_client_token(self) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Get client token, fetching if needed.
|
|
55
|
+
|
|
56
|
+
Proactively refreshes if token will expire within 60 seconds.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Client token string
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
AuthenticationError: If token fetch fails
|
|
63
|
+
"""
|
|
64
|
+
await self._initialize_client()
|
|
65
|
+
|
|
66
|
+
now = datetime.now()
|
|
67
|
+
|
|
68
|
+
# If token exists and not expired (with 60s buffer for proactive refresh), return it
|
|
69
|
+
if (
|
|
70
|
+
self.client_token
|
|
71
|
+
and self.token_expires_at
|
|
72
|
+
and self.token_expires_at > now + timedelta(seconds=60)
|
|
73
|
+
):
|
|
74
|
+
assert self.client_token is not None
|
|
75
|
+
return self.client_token
|
|
76
|
+
|
|
77
|
+
# Acquire lock to prevent concurrent token fetches
|
|
78
|
+
async with self.token_refresh_lock:
|
|
79
|
+
# Double-check after acquiring lock
|
|
80
|
+
if (
|
|
81
|
+
self.client_token
|
|
82
|
+
and self.token_expires_at
|
|
83
|
+
and self.token_expires_at > now + timedelta(seconds=60)
|
|
84
|
+
):
|
|
85
|
+
assert self.client_token is not None
|
|
86
|
+
return self.client_token
|
|
87
|
+
|
|
88
|
+
# Fetch new token
|
|
89
|
+
await self._fetch_client_token()
|
|
90
|
+
assert self.client_token is not None
|
|
91
|
+
return self.client_token
|
|
92
|
+
|
|
93
|
+
async def _fetch_client_token(self) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Fetch client token from controller.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
AuthenticationError: If token fetch fails
|
|
99
|
+
"""
|
|
100
|
+
await self._initialize_client()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Use a temporary client to avoid interceptor recursion
|
|
104
|
+
temp_client = httpx.AsyncClient(
|
|
105
|
+
base_url=self.config.controller_url,
|
|
106
|
+
timeout=30.0,
|
|
107
|
+
headers={
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
"x-client-id": self.config.client_id,
|
|
110
|
+
"x-client-secret": self.config.client_secret,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
response = await temp_client.post("/api/auth/token")
|
|
115
|
+
await temp_client.aclose()
|
|
116
|
+
|
|
117
|
+
if response.status_code != 200:
|
|
118
|
+
raise AuthenticationError(
|
|
119
|
+
f"Failed to get client token: HTTP {response.status_code}",
|
|
120
|
+
status_code=response.status_code,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
data = response.json()
|
|
124
|
+
token_response = ClientTokenResponse(**data)
|
|
125
|
+
|
|
126
|
+
if not token_response.success or not token_response.token:
|
|
127
|
+
raise AuthenticationError("Failed to get client token: Invalid response")
|
|
128
|
+
|
|
129
|
+
self.client_token = token_response.token
|
|
130
|
+
# Set expiration with 30 second buffer before actual expiration
|
|
131
|
+
expires_in = max(0, token_response.expiresIn - 30)
|
|
132
|
+
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
133
|
+
|
|
134
|
+
except httpx.HTTPError as e:
|
|
135
|
+
raise ConnectionError(f"Failed to get client token: {str(e)}")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
if isinstance(e, (AuthenticationError, ConnectionError)):
|
|
138
|
+
raise
|
|
139
|
+
raise AuthenticationError(f"Failed to get client token: {str(e)}")
|
|
140
|
+
|
|
141
|
+
async def _ensure_client_token(self):
|
|
142
|
+
"""Ensure client token is set in headers."""
|
|
143
|
+
token = await self._get_client_token()
|
|
144
|
+
if self.client:
|
|
145
|
+
self.client.headers["x-client-token"] = token
|
|
146
|
+
|
|
147
|
+
def _parse_error_response(self, response: httpx.Response, url: str) -> Optional[ErrorResponse]:
|
|
148
|
+
"""
|
|
149
|
+
Parse structured error response from HTTP response.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
response: HTTP response object
|
|
153
|
+
url: Request URL (used for instance URI if not in response)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
ErrorResponse if response matches structure, None otherwise
|
|
157
|
+
"""
|
|
158
|
+
if not response.headers.get("content-type", "").startswith("application/json"):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
response_data = response.json()
|
|
163
|
+
# Check if response matches ErrorResponse structure
|
|
164
|
+
if (
|
|
165
|
+
isinstance(response_data, dict)
|
|
166
|
+
and "errors" in response_data
|
|
167
|
+
and "type" in response_data
|
|
168
|
+
and "title" in response_data
|
|
169
|
+
and "statusCode" in response_data
|
|
170
|
+
):
|
|
171
|
+
# Set instance from URL if not provided
|
|
172
|
+
if "instance" not in response_data or not response_data["instance"]:
|
|
173
|
+
response_data["instance"] = url
|
|
174
|
+
return ErrorResponse(**response_data)
|
|
175
|
+
except (ValueError, TypeError, KeyError):
|
|
176
|
+
# JSON parsing failed or structure doesn't match
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
async def close(self):
|
|
182
|
+
"""Close the HTTP client."""
|
|
183
|
+
if self.client:
|
|
184
|
+
await self.client.aclose()
|
|
185
|
+
self.client = None
|
|
186
|
+
|
|
187
|
+
async def __aenter__(self):
|
|
188
|
+
"""Async context manager entry."""
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
192
|
+
"""Async context manager exit."""
|
|
193
|
+
await self.close()
|
|
194
|
+
|
|
195
|
+
async def get(self, url: str, **kwargs) -> Any:
|
|
196
|
+
"""
|
|
197
|
+
Make GET request.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
url: Request URL
|
|
201
|
+
**kwargs: Additional httpx request parameters
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Response data (JSON parsed)
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
MisoClientError: If request fails
|
|
208
|
+
"""
|
|
209
|
+
await self._initialize_client()
|
|
210
|
+
await self._ensure_client_token()
|
|
211
|
+
try:
|
|
212
|
+
assert self.client is not None
|
|
213
|
+
response = await self.client.get(url, **kwargs)
|
|
214
|
+
|
|
215
|
+
# Handle 401 - clear token to force refresh
|
|
216
|
+
if response.status_code == 401:
|
|
217
|
+
self.client_token = None
|
|
218
|
+
self.token_expires_at = None
|
|
219
|
+
|
|
220
|
+
response.raise_for_status()
|
|
221
|
+
return response.json()
|
|
222
|
+
except httpx.HTTPStatusError as e:
|
|
223
|
+
# Try to parse structured error response
|
|
224
|
+
error_response = self._parse_error_response(e.response, url)
|
|
225
|
+
error_body = {}
|
|
226
|
+
if (
|
|
227
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
228
|
+
and not error_response
|
|
229
|
+
):
|
|
230
|
+
try:
|
|
231
|
+
error_body = e.response.json()
|
|
232
|
+
except (ValueError, TypeError):
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
raise MisoClientError(
|
|
236
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
237
|
+
status_code=e.response.status_code,
|
|
238
|
+
error_body=error_body,
|
|
239
|
+
error_response=error_response,
|
|
240
|
+
)
|
|
241
|
+
except httpx.RequestError as e:
|
|
242
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
243
|
+
|
|
244
|
+
async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
245
|
+
"""
|
|
246
|
+
Make POST request.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
url: Request URL
|
|
250
|
+
data: Request data (will be JSON encoded)
|
|
251
|
+
**kwargs: Additional httpx request parameters
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Response data (JSON parsed)
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
MisoClientError: If request fails
|
|
258
|
+
"""
|
|
259
|
+
await self._initialize_client()
|
|
260
|
+
await self._ensure_client_token()
|
|
261
|
+
try:
|
|
262
|
+
assert self.client is not None
|
|
263
|
+
response = await self.client.post(url, json=data, **kwargs)
|
|
264
|
+
|
|
265
|
+
if response.status_code == 401:
|
|
266
|
+
self.client_token = None
|
|
267
|
+
self.token_expires_at = None
|
|
268
|
+
|
|
269
|
+
response.raise_for_status()
|
|
270
|
+
return response.json()
|
|
271
|
+
except httpx.HTTPStatusError as e:
|
|
272
|
+
# Try to parse structured error response
|
|
273
|
+
error_response = self._parse_error_response(e.response, url)
|
|
274
|
+
error_body = {}
|
|
275
|
+
if (
|
|
276
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
277
|
+
and not error_response
|
|
278
|
+
):
|
|
279
|
+
try:
|
|
280
|
+
error_body = e.response.json()
|
|
281
|
+
except (ValueError, TypeError):
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
raise MisoClientError(
|
|
285
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
286
|
+
status_code=e.response.status_code,
|
|
287
|
+
error_body=error_body,
|
|
288
|
+
error_response=error_response,
|
|
289
|
+
)
|
|
290
|
+
except httpx.RequestError as e:
|
|
291
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
292
|
+
|
|
293
|
+
async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
294
|
+
"""
|
|
295
|
+
Make PUT request.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
url: Request URL
|
|
299
|
+
data: Request data (will be JSON encoded)
|
|
300
|
+
**kwargs: Additional httpx request parameters
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Response data (JSON parsed)
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
MisoClientError: If request fails
|
|
307
|
+
"""
|
|
308
|
+
await self._initialize_client()
|
|
309
|
+
await self._ensure_client_token()
|
|
310
|
+
try:
|
|
311
|
+
assert self.client is not None
|
|
312
|
+
response = await self.client.put(url, json=data, **kwargs)
|
|
313
|
+
|
|
314
|
+
if response.status_code == 401:
|
|
315
|
+
self.client_token = None
|
|
316
|
+
self.token_expires_at = None
|
|
317
|
+
|
|
318
|
+
response.raise_for_status()
|
|
319
|
+
return response.json()
|
|
320
|
+
except httpx.HTTPStatusError as e:
|
|
321
|
+
# Try to parse structured error response
|
|
322
|
+
error_response = self._parse_error_response(e.response, url)
|
|
323
|
+
error_body = {}
|
|
324
|
+
if (
|
|
325
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
326
|
+
and not error_response
|
|
327
|
+
):
|
|
328
|
+
try:
|
|
329
|
+
error_body = e.response.json()
|
|
330
|
+
except (ValueError, TypeError):
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
raise MisoClientError(
|
|
334
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
335
|
+
status_code=e.response.status_code,
|
|
336
|
+
error_body=error_body,
|
|
337
|
+
error_response=error_response,
|
|
338
|
+
)
|
|
339
|
+
except httpx.RequestError as e:
|
|
340
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
341
|
+
|
|
342
|
+
async def delete(self, url: str, **kwargs) -> Any:
|
|
343
|
+
"""
|
|
344
|
+
Make DELETE request.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
url: Request URL
|
|
348
|
+
**kwargs: Additional httpx request parameters
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Response data (JSON parsed)
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
MisoClientError: If request fails
|
|
355
|
+
"""
|
|
356
|
+
await self._initialize_client()
|
|
357
|
+
await self._ensure_client_token()
|
|
358
|
+
try:
|
|
359
|
+
assert self.client is not None
|
|
360
|
+
response = await self.client.delete(url, **kwargs)
|
|
361
|
+
|
|
362
|
+
if response.status_code == 401:
|
|
363
|
+
self.client_token = None
|
|
364
|
+
self.token_expires_at = None
|
|
365
|
+
|
|
366
|
+
response.raise_for_status()
|
|
367
|
+
return response.json()
|
|
368
|
+
except httpx.HTTPStatusError as e:
|
|
369
|
+
# Try to parse structured error response
|
|
370
|
+
error_response = self._parse_error_response(e.response, url)
|
|
371
|
+
error_body = {}
|
|
372
|
+
if (
|
|
373
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
374
|
+
and not error_response
|
|
375
|
+
):
|
|
376
|
+
try:
|
|
377
|
+
error_body = e.response.json()
|
|
378
|
+
except (ValueError, TypeError):
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
raise MisoClientError(
|
|
382
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
383
|
+
status_code=e.response.status_code,
|
|
384
|
+
error_body=error_body,
|
|
385
|
+
error_response=error_response,
|
|
386
|
+
)
|
|
387
|
+
except httpx.RequestError as e:
|
|
388
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
389
|
+
|
|
390
|
+
async def request(
|
|
391
|
+
self,
|
|
392
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
393
|
+
url: str,
|
|
394
|
+
data: Optional[Dict[str, Any]] = None,
|
|
395
|
+
**kwargs,
|
|
396
|
+
) -> Any:
|
|
397
|
+
"""
|
|
398
|
+
Generic request method.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
method: HTTP method
|
|
402
|
+
url: Request URL
|
|
403
|
+
data: Request data (for POST/PUT)
|
|
404
|
+
**kwargs: Additional httpx request parameters
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Response data (JSON parsed)
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
MisoClientError: If request fails
|
|
411
|
+
"""
|
|
412
|
+
method_upper = method.upper()
|
|
413
|
+
if method_upper == "GET":
|
|
414
|
+
return await self.get(url, **kwargs)
|
|
415
|
+
elif method_upper == "POST":
|
|
416
|
+
return await self.post(url, data, **kwargs)
|
|
417
|
+
elif method_upper == "PUT":
|
|
418
|
+
return await self.put(url, data, **kwargs)
|
|
419
|
+
elif method_upper == "DELETE":
|
|
420
|
+
return await self.delete(url, **kwargs)
|
|
421
|
+
else:
|
|
422
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
423
|
+
|
|
424
|
+
async def authenticated_request(
|
|
425
|
+
self,
|
|
426
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
427
|
+
url: str,
|
|
428
|
+
token: str,
|
|
429
|
+
data: Optional[Dict[str, Any]] = None,
|
|
430
|
+
**kwargs,
|
|
431
|
+
) -> Any:
|
|
432
|
+
"""
|
|
433
|
+
Make authenticated request with Bearer token.
|
|
434
|
+
|
|
435
|
+
IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
|
|
436
|
+
User token is sent as Authorization: Bearer header (this method parameter)
|
|
437
|
+
These are two separate tokens for different purposes.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
method: HTTP method
|
|
441
|
+
url: Request URL
|
|
442
|
+
token: User authentication token (sent as Bearer token)
|
|
443
|
+
data: Request data (for POST/PUT)
|
|
444
|
+
**kwargs: Additional httpx request parameters
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Response data (JSON parsed)
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
MisoClientError: If request fails
|
|
451
|
+
"""
|
|
452
|
+
await self._ensure_client_token()
|
|
453
|
+
|
|
454
|
+
# Add Bearer token for user authentication
|
|
455
|
+
# x-client-token is automatically added by _ensure_client_token
|
|
456
|
+
headers = kwargs.get("headers", {})
|
|
457
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
458
|
+
kwargs["headers"] = headers
|
|
459
|
+
|
|
460
|
+
return await self.request(method, url, data, **kwargs)
|
|
461
|
+
|
|
462
|
+
async def get_environment_token(self) -> str:
|
|
463
|
+
"""
|
|
464
|
+
Get environment token using client credentials.
|
|
465
|
+
|
|
466
|
+
This is called automatically by HttpClient but can be called manually.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Client token string
|
|
470
|
+
"""
|
|
471
|
+
return await self._get_client_token()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sensitive fields configuration loader for ISO 27001 compliance.
|
|
3
|
+
|
|
4
|
+
This module provides utilities to load and merge sensitive fields configuration
|
|
5
|
+
from JSON files, supporting custom configuration paths and environment variables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
# Default path to sensitive fields config relative to this file
|
|
14
|
+
_DEFAULT_CONFIG_PATH = Path(__file__).parent / "sensitive_fields_config.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_sensitive_fields_config(
|
|
18
|
+
config_path: Optional[str] = None,
|
|
19
|
+
) -> Dict[str, Any]:
|
|
20
|
+
"""
|
|
21
|
+
Load sensitive fields configuration from JSON file.
|
|
22
|
+
|
|
23
|
+
Supports custom path via:
|
|
24
|
+
1. config_path parameter
|
|
25
|
+
2. MISO_SENSITIVE_FIELDS_CONFIG environment variable
|
|
26
|
+
3. Default path: miso_client/utils/sensitive_fields_config.json
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
config_path: Optional custom path to JSON config file
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Dictionary with 'fields' and 'fieldPatterns' keys
|
|
33
|
+
Returns empty dict if file cannot be loaded
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> config = load_sensitive_fields_config()
|
|
37
|
+
>>> fields = config.get('fields', {})
|
|
38
|
+
"""
|
|
39
|
+
# Priority: parameter > environment variable > default
|
|
40
|
+
if config_path:
|
|
41
|
+
file_path = Path(config_path)
|
|
42
|
+
elif os.environ.get("MISO_SENSITIVE_FIELDS_CONFIG"):
|
|
43
|
+
file_path = Path(os.environ["MISO_SENSITIVE_FIELDS_CONFIG"])
|
|
44
|
+
else:
|
|
45
|
+
file_path = _DEFAULT_CONFIG_PATH
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
49
|
+
config = json.load(f)
|
|
50
|
+
# Validate structure
|
|
51
|
+
if isinstance(config, dict):
|
|
52
|
+
return config
|
|
53
|
+
return {}
|
|
54
|
+
except (FileNotFoundError, json.JSONDecodeError, IOError, OSError):
|
|
55
|
+
# File not found, invalid JSON, or permission error
|
|
56
|
+
# Return empty dict - fallback to hardcoded defaults
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_sensitive_fields_array(
|
|
61
|
+
config_path: Optional[str] = None,
|
|
62
|
+
) -> List[str]:
|
|
63
|
+
"""
|
|
64
|
+
Get flattened array of all sensitive field names from configuration.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
config_path: Optional custom path to JSON config file
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Flattened list of all sensitive field names from all categories
|
|
71
|
+
Returns empty list if config cannot be loaded
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
>>> fields = get_sensitive_fields_array()
|
|
75
|
+
>>> assert 'password' in fields
|
|
76
|
+
>>> assert 'token' in fields
|
|
77
|
+
"""
|
|
78
|
+
config = load_sensitive_fields_config(config_path)
|
|
79
|
+
fields_dict = config.get("fields", {})
|
|
80
|
+
|
|
81
|
+
# Flatten all categories into single list
|
|
82
|
+
all_fields: List[str] = []
|
|
83
|
+
if isinstance(fields_dict, dict):
|
|
84
|
+
for category_fields in fields_dict.values():
|
|
85
|
+
if isinstance(category_fields, list):
|
|
86
|
+
all_fields.extend(category_fields)
|
|
87
|
+
|
|
88
|
+
# Remove duplicates while preserving order
|
|
89
|
+
seen = set()
|
|
90
|
+
unique_fields = []
|
|
91
|
+
for field in all_fields:
|
|
92
|
+
if field.lower() not in seen:
|
|
93
|
+
seen.add(field.lower())
|
|
94
|
+
unique_fields.append(field)
|
|
95
|
+
|
|
96
|
+
return unique_fields
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_field_patterns(config_path: Optional[str] = None) -> List[str]:
|
|
100
|
+
"""
|
|
101
|
+
Get field pattern matching rules from configuration.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
config_path: Optional custom path to JSON config file
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of field pattern matching rules
|
|
108
|
+
Returns empty list if config cannot be loaded or no patterns defined
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> patterns = get_field_patterns()
|
|
112
|
+
>>> # Patterns can be regex patterns or simple matching rules
|
|
113
|
+
"""
|
|
114
|
+
config = load_sensitive_fields_config(config_path)
|
|
115
|
+
patterns = config.get("fieldPatterns", [])
|
|
116
|
+
return patterns if isinstance(patterns, list) else []
|