miso-client 0.1.0__py3-none-any.whl → 3.7.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.
Files changed (69) hide show
  1. miso_client/__init__.py +523 -130
  2. miso_client/api/__init__.py +35 -0
  3. miso_client/api/auth_api.py +367 -0
  4. miso_client/api/logs_api.py +91 -0
  5. miso_client/api/permissions_api.py +88 -0
  6. miso_client/api/roles_api.py +88 -0
  7. miso_client/api/types/__init__.py +75 -0
  8. miso_client/api/types/auth_types.py +183 -0
  9. miso_client/api/types/logs_types.py +71 -0
  10. miso_client/api/types/permissions_types.py +31 -0
  11. miso_client/api/types/roles_types.py +31 -0
  12. miso_client/errors.py +30 -4
  13. miso_client/models/__init__.py +4 -0
  14. miso_client/models/config.py +275 -72
  15. miso_client/models/error_response.py +39 -0
  16. miso_client/models/filter.py +255 -0
  17. miso_client/models/pagination.py +44 -0
  18. miso_client/models/sort.py +25 -0
  19. miso_client/services/__init__.py +6 -5
  20. miso_client/services/auth.py +496 -87
  21. miso_client/services/cache.py +42 -41
  22. miso_client/services/encryption.py +18 -17
  23. miso_client/services/logger.py +467 -328
  24. miso_client/services/logger_chain.py +288 -0
  25. miso_client/services/permission.py +130 -67
  26. miso_client/services/redis.py +28 -23
  27. miso_client/services/role.py +145 -62
  28. miso_client/utils/__init__.py +3 -3
  29. miso_client/utils/audit_log_queue.py +222 -0
  30. miso_client/utils/auth_strategy.py +88 -0
  31. miso_client/utils/auth_utils.py +65 -0
  32. miso_client/utils/circuit_breaker.py +125 -0
  33. miso_client/utils/client_token_manager.py +244 -0
  34. miso_client/utils/config_loader.py +88 -17
  35. miso_client/utils/controller_url_resolver.py +80 -0
  36. miso_client/utils/data_masker.py +104 -33
  37. miso_client/utils/environment_token.py +126 -0
  38. miso_client/utils/error_utils.py +216 -0
  39. miso_client/utils/fastapi_endpoints.py +166 -0
  40. miso_client/utils/filter.py +364 -0
  41. miso_client/utils/filter_applier.py +143 -0
  42. miso_client/utils/filter_parser.py +110 -0
  43. miso_client/utils/flask_endpoints.py +169 -0
  44. miso_client/utils/http_client.py +494 -262
  45. miso_client/utils/http_client_logging.py +352 -0
  46. miso_client/utils/http_client_logging_helpers.py +197 -0
  47. miso_client/utils/http_client_query_helpers.py +138 -0
  48. miso_client/utils/http_error_handler.py +92 -0
  49. miso_client/utils/http_log_formatter.py +115 -0
  50. miso_client/utils/http_log_masker.py +203 -0
  51. miso_client/utils/internal_http_client.py +435 -0
  52. miso_client/utils/jwt_tools.py +125 -16
  53. miso_client/utils/logger_helpers.py +206 -0
  54. miso_client/utils/logging_helpers.py +70 -0
  55. miso_client/utils/origin_validator.py +128 -0
  56. miso_client/utils/pagination.py +275 -0
  57. miso_client/utils/request_context.py +285 -0
  58. miso_client/utils/sensitive_fields_loader.py +116 -0
  59. miso_client/utils/sort.py +116 -0
  60. miso_client/utils/token_utils.py +114 -0
  61. miso_client/utils/url_validator.py +66 -0
  62. miso_client/utils/user_token_refresh.py +245 -0
  63. miso_client-3.7.2.dist-info/METADATA +1021 -0
  64. miso_client-3.7.2.dist-info/RECORD +68 -0
  65. miso_client-0.1.0.dist-info/METADATA +0 -551
  66. miso_client-0.1.0.dist-info/RECORD +0 -23
  67. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
  68. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
  69. {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
@@ -5,16 +5,18 @@ Implements ISO 27001 data protection controls by masking sensitive fields
5
5
  in log entries and context data.
6
6
  """
7
7
 
8
- from typing import Any, Set
8
+ from typing import Any, Optional, Set
9
+
10
+ from .sensitive_fields_loader import get_sensitive_fields_array
9
11
 
10
12
 
11
13
  class DataMasker:
12
14
  """Static class for masking sensitive data."""
13
-
15
+
14
16
  MASKED_VALUE = "***MASKED***"
15
-
16
- # Set of sensitive field names (normalized)
17
- _sensitive_fields: Set[str] = {
17
+
18
+ # Hardcoded set of sensitive field names (normalized) - fallback if JSON cannot be loaded
19
+ _hardcoded_sensitive_fields: Set[str] = {
18
20
  "password",
19
21
  "passwd",
20
22
  "pwd",
@@ -37,58 +39,128 @@ class DataMasker:
37
39
  "privatekey",
38
40
  "secretkey",
39
41
  }
40
-
42
+
43
+ # Cached merged sensitive fields (loaded on first use)
44
+ _sensitive_fields: Optional[Set[str]] = None
45
+ _config_loaded: bool = False
46
+
47
+ @classmethod
48
+ def _load_config(cls, config_path: Optional[str] = None) -> None:
49
+ """
50
+ Load sensitive fields configuration from JSON and merge with hardcoded defaults.
51
+
52
+ This method is called automatically on first use. It loads JSON configuration
53
+ and merges it with hardcoded defaults, ensuring backward compatibility.
54
+
55
+ Args:
56
+ config_path: Optional custom path to JSON config file
57
+ """
58
+ if cls._config_loaded:
59
+ return
60
+
61
+ # Start with hardcoded fields as base
62
+ merged_fields = set(cls._hardcoded_sensitive_fields)
63
+
64
+ try:
65
+ # Try to load fields from JSON configuration
66
+ json_fields = get_sensitive_fields_array(config_path)
67
+ if json_fields:
68
+ # Normalize and add JSON fields (same normalization as hardcoded fields)
69
+ for field in json_fields:
70
+ if isinstance(field, str):
71
+ # Normalize: lowercase and remove underscores/hyphens
72
+ normalized = field.lower().replace("_", "").replace("-", "")
73
+ merged_fields.add(normalized)
74
+ except Exception:
75
+ # If JSON loading fails, fall back to hardcoded fields only
76
+ pass
77
+
78
+ cls._sensitive_fields = merged_fields
79
+ cls._config_loaded = True
80
+
81
+ @classmethod
82
+ def _get_sensitive_fields(cls) -> Set[str]:
83
+ """
84
+ Get the set of sensitive fields (loads config on first call).
85
+
86
+ Returns:
87
+ Set of normalized sensitive field names
88
+ """
89
+ if not cls._config_loaded:
90
+ cls._load_config()
91
+ assert cls._sensitive_fields is not None
92
+ return cls._sensitive_fields
93
+
94
+ @classmethod
95
+ def set_config_path(cls, config_path: str) -> None:
96
+ """
97
+ Set custom path for sensitive fields configuration.
98
+
99
+ Must be called before first use of DataMasker methods if custom path is needed.
100
+ Otherwise, default path or environment variable will be used.
101
+
102
+ Args:
103
+ config_path: Path to JSON configuration file
104
+ """
105
+ # Reset cache to force reload with new path
106
+ cls._config_loaded = False
107
+ cls._sensitive_fields = None
108
+ cls._load_config(config_path)
109
+
41
110
  @classmethod
42
111
  def is_sensitive_field(cls, key: str) -> bool:
43
112
  """
44
113
  Check if a field name indicates sensitive data.
45
-
114
+
46
115
  Args:
47
116
  key: Field name to check
48
-
117
+
49
118
  Returns:
50
119
  True if field is sensitive, False otherwise
51
120
  """
52
121
  # Normalize key: lowercase and remove underscores/hyphens
53
122
  normalized_key = key.lower().replace("_", "").replace("-", "")
54
-
123
+
124
+ # Get sensitive fields (loads config on first use)
125
+ sensitive_fields = cls._get_sensitive_fields()
126
+
55
127
  # Check exact match
56
- if normalized_key in cls._sensitive_fields:
128
+ if normalized_key in sensitive_fields:
57
129
  return True
58
-
130
+
59
131
  # Check if field contains sensitive keywords
60
- for sensitive_field in cls._sensitive_fields:
132
+ for sensitive_field in sensitive_fields:
61
133
  if sensitive_field in normalized_key:
62
134
  return True
63
-
135
+
64
136
  return False
65
-
137
+
66
138
  @classmethod
67
139
  def mask_sensitive_data(cls, data: Any) -> Any:
68
140
  """
69
141
  Mask sensitive data in objects, arrays, or primitives.
70
-
142
+
71
143
  Returns a masked copy without modifying the original.
72
144
  Recursively processes nested objects and arrays.
73
-
145
+
74
146
  Args:
75
147
  data: Data to mask (dict, list, or primitive)
76
-
148
+
77
149
  Returns:
78
150
  Masked copy of the data
79
151
  """
80
152
  # Handle null and undefined
81
153
  if data is None:
82
154
  return data
83
-
155
+
84
156
  # Handle primitives (string, number, boolean)
85
157
  if not isinstance(data, (dict, list)):
86
158
  return data
87
-
159
+
88
160
  # Handle arrays
89
161
  if isinstance(data, list):
90
162
  return [cls.mask_sensitive_data(item) for item in data]
91
-
163
+
92
164
  # Handle objects/dicts
93
165
  masked: dict[str, Any] = {}
94
166
  for key, value in data.items():
@@ -101,49 +173,49 @@ class DataMasker:
101
173
  else:
102
174
  # Keep non-sensitive value as-is
103
175
  masked[key] = value
104
-
176
+
105
177
  return masked
106
-
178
+
107
179
  @classmethod
108
180
  def mask_value(cls, value: str, show_first: int = 0, show_last: int = 0) -> str:
109
181
  """
110
182
  Mask specific value (useful for masking individual strings).
111
-
183
+
112
184
  Args:
113
185
  value: String value to mask
114
186
  show_first: Number of characters to show at the start
115
187
  show_last: Number of characters to show at the end
116
-
188
+
117
189
  Returns:
118
190
  Masked string value
119
191
  """
120
192
  if not value or len(value) <= show_first + show_last:
121
193
  return cls.MASKED_VALUE
122
-
194
+
123
195
  first = value[:show_first] if show_first > 0 else ""
124
196
  last = value[-show_last:] if show_last > 0 else ""
125
197
  masked_length = max(8, len(value) - show_first - show_last)
126
198
  masked = "*" * masked_length
127
-
199
+
128
200
  return f"{first}{masked}{last}"
129
-
201
+
130
202
  @classmethod
131
203
  def contains_sensitive_data(cls, data: Any) -> bool:
132
204
  """
133
205
  Check if data contains sensitive information.
134
-
206
+
135
207
  Args:
136
208
  data: Data to check
137
-
209
+
138
210
  Returns:
139
211
  True if data contains sensitive fields, False otherwise
140
212
  """
141
213
  if data is None or not isinstance(data, (dict, list)):
142
214
  return False
143
-
215
+
144
216
  if isinstance(data, list):
145
217
  return any(cls.contains_sensitive_data(item) for item in data)
146
-
218
+
147
219
  # Check object keys
148
220
  for key, value in data.items():
149
221
  if cls.is_sensitive_field(key):
@@ -151,6 +223,5 @@ class DataMasker:
151
223
  if isinstance(value, (dict, list)):
152
224
  if cls.contains_sensitive_data(value):
153
225
  return True
154
-
155
- return False
156
226
 
227
+ return False
@@ -0,0 +1,126 @@
1
+ """
2
+ Server-side environment token wrapper with origin validation and audit logging.
3
+
4
+ This module provides a secure server-side wrapper for fetching environment tokens
5
+ with origin validation and ISO 27001 compliant audit logging.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from ..errors import AuthenticationError
11
+ from ..services.logger import LoggerService
12
+ from .data_masker import DataMasker
13
+ from .origin_validator import validate_origin
14
+
15
+
16
+ async def get_environment_token(miso_client: Any, headers: Any) -> str:
17
+ """
18
+ Get environment token with origin validation and audit logging.
19
+
20
+ This is a server-side wrapper that validates request origin before calling
21
+ the controller, and logs audit events with ISO 27001 compliant data masking.
22
+
23
+ Args:
24
+ miso_client: MisoClient instance
25
+ headers: Request headers dict or object with headers attribute
26
+
27
+ Returns:
28
+ Client token string
29
+
30
+ Raises:
31
+ AuthenticationError: If origin validation fails or token fetch fails
32
+
33
+ Example:
34
+ >>> from miso_client import MisoClient, MisoClientConfig
35
+ >>> config = MisoClientConfig(...)
36
+ >>> client = MisoClient(config)
37
+ >>> headers = {"origin": "http://localhost:3000"}
38
+ >>> token = await get_environment_token(client, headers)
39
+ """
40
+ config = miso_client.config
41
+ logger: LoggerService = miso_client.logger
42
+
43
+ # Validate origin if allowedOrigins is configured
44
+ if config.allowedOrigins:
45
+ validation_result = validate_origin(headers, config.allowedOrigins)
46
+ if not validation_result["valid"]:
47
+ error_message = validation_result.get("error", "Origin validation failed")
48
+
49
+ # Log error and audit event before raising exception
50
+ masked_config = {
51
+ "clientId": config.client_id,
52
+ "clientSecret": DataMasker.mask_sensitive_data(config.client_secret),
53
+ }
54
+
55
+ await logger.error(
56
+ "Origin validation failed for environment token request",
57
+ context={
58
+ "error": error_message,
59
+ "allowedOrigins": config.allowedOrigins,
60
+ "clientId": config.client_id,
61
+ },
62
+ )
63
+
64
+ await logger.audit(
65
+ "auth.environment_token.origin_validation_failed",
66
+ resource="/api/v1/auth/token",
67
+ context={
68
+ "error": error_message,
69
+ "allowedOrigins": config.allowedOrigins,
70
+ **masked_config,
71
+ },
72
+ )
73
+
74
+ raise AuthenticationError(f"Origin validation failed: {error_message}")
75
+
76
+ # Log audit event before calling controller (with masked credentials)
77
+ masked_config = {
78
+ "clientId": config.client_id,
79
+ "clientSecret": DataMasker.mask_sensitive_data(config.client_secret),
80
+ }
81
+
82
+ await logger.audit(
83
+ "auth.environment_token.request",
84
+ resource=config.clientTokenUri or "/api/v1/auth/token",
85
+ context=masked_config,
86
+ )
87
+
88
+ try:
89
+ # Call auth service to get environment token
90
+ token: str = await miso_client.auth.get_environment_token()
91
+
92
+ # Log successful token fetch
93
+ await logger.audit(
94
+ "auth.environment_token.success",
95
+ resource=config.clientTokenUri or "/api/v1/auth/token",
96
+ context={
97
+ "clientId": config.client_id,
98
+ "tokenLength": len(token) if token else 0,
99
+ },
100
+ )
101
+
102
+ return token
103
+
104
+ except Exception as error:
105
+ # Log error and audit event
106
+ await logger.error(
107
+ "Failed to get environment token",
108
+ context={
109
+ "error": str(error),
110
+ "clientId": config.client_id,
111
+ },
112
+ )
113
+
114
+ await logger.audit(
115
+ "auth.environment_token.failure",
116
+ resource=config.clientTokenUri or "/api/v1/auth/token",
117
+ context={
118
+ "error": str(error),
119
+ **masked_config,
120
+ },
121
+ )
122
+
123
+ # Re-raise as AuthenticationError
124
+ if isinstance(error, AuthenticationError):
125
+ raise
126
+ raise AuthenticationError(f"Failed to get environment token: {str(error)}") from error
@@ -0,0 +1,216 @@
1
+ """
2
+ Error utilities for MisoClient SDK.
3
+
4
+ This module provides error transformation utilities for handling
5
+ camelCase error responses from the API.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from ..errors import MisoClientError
11
+ from ..models.error_response import ErrorResponse
12
+
13
+
14
+ class ApiErrorException(Exception):
15
+ """
16
+ Exception class for camelCase error responses.
17
+
18
+ Used with camelCase ErrorResponse format matching TypeScript SDK.
19
+ """
20
+
21
+ def __init__(self, error: ErrorResponse):
22
+ """
23
+ Initialize ApiErrorException.
24
+
25
+ Args:
26
+ error: ErrorResponse object with camelCase properties
27
+ """
28
+ super().__init__(error.title or "API Error")
29
+ self.name = "ApiErrorException"
30
+ self.statusCode = error.statusCode
31
+ self.correlationId = error.correlationId
32
+ self.type = error.type
33
+ self.instance = error.instance
34
+ self.errors = error.errors
35
+
36
+
37
+ def transformError(error_data: dict) -> ErrorResponse:
38
+ """
39
+ Transform arbitrary error into standardized camelCase ErrorResponse.
40
+
41
+ Converts error data dictionary to ErrorResponse object with camelCase field names.
42
+
43
+ Args:
44
+ error_data: Dictionary with error data (must be camelCase)
45
+
46
+ Returns:
47
+ ErrorResponse object with standardized format
48
+
49
+ Examples:
50
+ >>> error_data = {
51
+ ... 'errors': ['Error message'],
52
+ ... 'type': '/Errors/Bad Input',
53
+ ... 'title': 'Bad Request',
54
+ ... 'statusCode': 400,
55
+ ... 'instance': '/api/endpoint'
56
+ ... }
57
+ >>> error_response = transformError(error_data)
58
+ >>> error_response.statusCode
59
+ 400
60
+ """
61
+ return ErrorResponse(**error_data)
62
+
63
+
64
+ # Alias for backward compatibility
65
+ transform_error_to_snake_case = transformError
66
+
67
+
68
+ def handleApiError(
69
+ response_data: dict, status_code: int, instance: Optional[str] = None
70
+ ) -> ApiErrorException:
71
+ """
72
+ Handle API error and raise camelCase ApiErrorException.
73
+
74
+ Creates ApiErrorException from camelCase API response.
75
+
76
+ Args:
77
+ response_data: Error response data from API (must be camelCase)
78
+ status_code: HTTP status code (overrides statusCode in response_data)
79
+ instance: Optional request instance URI (overrides instance in response_data)
80
+
81
+ Returns:
82
+ ApiErrorException with camelCase error format
83
+
84
+ Raises:
85
+ ApiErrorException: Always raises this exception
86
+
87
+ Examples:
88
+ >>> response_data = {
89
+ ... 'errors': ['Validation failed'],
90
+ ... 'type': '/Errors/Validation',
91
+ ... 'title': 'Validation Error',
92
+ ... 'statusCode': 422
93
+ ... }
94
+ >>> try:
95
+ ... handleApiError(response_data, 422, '/api/endpoint')
96
+ ... except ApiErrorException as e:
97
+ ... e.statusCode
98
+ 422
99
+ """
100
+ # Create a copy to avoid mutating the original
101
+ data = response_data.copy()
102
+
103
+ # Override instance if provided
104
+ if instance:
105
+ data["instance"] = instance
106
+
107
+ # Override statusCode if provided
108
+ data["statusCode"] = status_code
109
+
110
+ # Ensure title has a default if missing
111
+ if "title" not in data:
112
+ data["title"] = None
113
+
114
+ # Transform to ErrorResponse
115
+ error_response = transformError(data)
116
+
117
+ # Raise ApiErrorException
118
+ raise ApiErrorException(error_response)
119
+
120
+
121
+ def handle_api_error_snake_case(
122
+ response_data: dict, status_code: int, instance: Optional[str] = None
123
+ ) -> MisoClientError:
124
+ """
125
+ Handle errors with camelCase response format (legacy function).
126
+
127
+ Creates MisoClientError with ErrorResponse from camelCase API response.
128
+ This is kept for backward compatibility. New code should use handleApiError().
129
+
130
+ Args:
131
+ response_data: Error response data from API (must be camelCase)
132
+ status_code: HTTP status code (overrides statusCode in response_data)
133
+ instance: Optional request instance URI (overrides instance in response_data)
134
+
135
+ Returns:
136
+ MisoClientError with structured ErrorResponse
137
+
138
+ Examples:
139
+ >>> response_data = {
140
+ ... 'errors': ['Validation failed'],
141
+ ... 'type': '/Errors/Validation',
142
+ ... 'title': 'Validation Error',
143
+ ... 'statusCode': 422
144
+ ... }
145
+ >>> error = handle_api_error_snake_case(response_data, 422, '/api/endpoint')
146
+ >>> error.error_response.statusCode
147
+ 422
148
+ """
149
+ # Create a copy to avoid mutating the original
150
+ data = response_data.copy()
151
+
152
+ # Override instance if provided
153
+ if instance:
154
+ data["instance"] = instance
155
+
156
+ # Override statusCode if provided
157
+ data["statusCode"] = status_code
158
+
159
+ # Ensure title has a default if missing
160
+ if "title" not in data:
161
+ data["title"] = None
162
+
163
+ # Transform to ErrorResponse
164
+ error_response = transformError(data)
165
+
166
+ # Create error message from errors list
167
+ if error_response.errors:
168
+ if len(error_response.errors) == 1:
169
+ message = error_response.errors[0]
170
+ else:
171
+ title_prefix = f"{error_response.title}: " if error_response.title else ""
172
+ message = f"{title_prefix}{'; '.join(error_response.errors)}"
173
+ else:
174
+ message = error_response.title or "API Error"
175
+
176
+ # Create MisoClientError with ErrorResponse
177
+ return MisoClientError(
178
+ message=message,
179
+ status_code=status_code,
180
+ error_response=error_response,
181
+ )
182
+
183
+
184
+ def extract_correlation_id_from_error(error: Exception) -> Optional[str]:
185
+ """
186
+ Extract correlation ID from exception if available.
187
+
188
+ Checks MisoClientError.error_response.correlationId and ApiErrorException.correlationId.
189
+
190
+ Args:
191
+ error: Exception object
192
+
193
+ Returns:
194
+ Correlation ID string if found, None otherwise
195
+
196
+ Examples:
197
+ >>> error = MisoClientError("Error", error_response=ErrorResponse(
198
+ ... errors=["Error"], type="/Errors/Test", statusCode=400,
199
+ ... correlationId="req-123"
200
+ ... ))
201
+ >>> extract_correlation_id_from_error(error)
202
+ 'req-123'
203
+ """
204
+ # Check MisoClientError with error_response
205
+ if isinstance(error, MisoClientError) and error.error_response:
206
+ correlation_id = error.error_response.correlationId
207
+ if correlation_id is not None:
208
+ return str(correlation_id)
209
+
210
+ # Check ApiErrorException with correlationId property
211
+ if isinstance(error, ApiErrorException):
212
+ correlation_id = error.correlationId
213
+ if correlation_id is not None:
214
+ return str(correlation_id)
215
+
216
+ return None