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.

@@ -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 []