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
@@ -0,0 +1,244 @@
1
+ """
2
+ Client token manager for InternalHttpClient.
3
+
4
+ This module provides client token management functionality including token fetching,
5
+ caching, and correlation ID extraction.
6
+ """
7
+
8
+ import asyncio
9
+ from datetime import datetime, timedelta
10
+ from typing import Optional
11
+
12
+ import httpx
13
+
14
+ from ..errors import AuthenticationError, ConnectionError
15
+ from ..models.config import ClientTokenResponse, MisoClientConfig
16
+ from .controller_url_resolver import resolve_controller_url
17
+ from .jwt_tools import decode_token
18
+
19
+
20
+ class ClientTokenManager:
21
+ """
22
+ Manages client token lifecycle including fetching, caching, and expiration.
23
+
24
+ This class handles all client token operations for InternalHttpClient.
25
+ """
26
+
27
+ def __init__(self, config: MisoClientConfig):
28
+ """
29
+ Initialize client token manager.
30
+
31
+ Args:
32
+ config: MisoClient configuration
33
+ """
34
+ self.config = config
35
+ self.client_token: Optional[str] = None
36
+ self.token_expires_at: Optional[datetime] = None
37
+ self.token_refresh_lock = asyncio.Lock()
38
+
39
+ def extract_correlation_id(self, response: Optional[httpx.Response] = None) -> Optional[str]:
40
+ """
41
+ Extract correlation ID from response headers.
42
+
43
+ Checks common correlation ID header names.
44
+
45
+ Args:
46
+ response: HTTP response object (optional)
47
+
48
+ Returns:
49
+ Correlation ID string if found, None otherwise
50
+ """
51
+ if not response:
52
+ return None
53
+
54
+ # Check common correlation ID header names (case-insensitive)
55
+ correlation_headers = [
56
+ "x-correlation-id",
57
+ "x-request-id",
58
+ "correlation-id",
59
+ "correlationId",
60
+ "x-correlationid",
61
+ "request-id",
62
+ ]
63
+
64
+ for header_name in correlation_headers:
65
+ correlation_id = response.headers.get(header_name) or response.headers.get(
66
+ header_name.lower()
67
+ )
68
+ if correlation_id:
69
+ return str(correlation_id)
70
+
71
+ return None
72
+
73
+ async def get_client_token(self) -> str:
74
+ """
75
+ Get client token, fetching if needed.
76
+
77
+ Proactively refreshes if token will expire within 60 seconds.
78
+
79
+ Returns:
80
+ Client token string
81
+
82
+ Raises:
83
+ AuthenticationError: If token fetch fails
84
+ """
85
+ now = datetime.now()
86
+
87
+ # If token exists and not expired (with 60s buffer for proactive refresh), return it
88
+ if (
89
+ self.client_token
90
+ and self.token_expires_at
91
+ and self.token_expires_at > now + timedelta(seconds=60)
92
+ ):
93
+ assert self.client_token is not None
94
+ return self.client_token
95
+
96
+ # Acquire lock to prevent concurrent token fetches
97
+ async with self.token_refresh_lock:
98
+ # Double-check after acquiring lock
99
+ if (
100
+ self.client_token
101
+ and self.token_expires_at
102
+ and self.token_expires_at > now + timedelta(seconds=60)
103
+ ):
104
+ assert self.client_token is not None
105
+ return self.client_token
106
+
107
+ # Fetch new token
108
+ await self.fetch_client_token()
109
+ assert self.client_token is not None
110
+ return self.client_token
111
+
112
+ async def fetch_client_token(self) -> None:
113
+ """
114
+ Fetch client token from controller.
115
+
116
+ Raises:
117
+ AuthenticationError: If token fetch fails
118
+ """
119
+ client_id = self.config.client_id
120
+ response: Optional[httpx.Response] = None
121
+ correlation_id: Optional[str] = None
122
+
123
+ try:
124
+ # Use resolved URL for temporary client
125
+ resolved_url = resolve_controller_url(self.config)
126
+ # Use a temporary client to avoid interceptor recursion
127
+ temp_client = httpx.AsyncClient(
128
+ base_url=resolved_url,
129
+ timeout=30.0,
130
+ headers={
131
+ "Content-Type": "application/json",
132
+ "x-client-id": client_id,
133
+ "x-client-secret": self.config.client_secret,
134
+ },
135
+ )
136
+
137
+ # Use configurable client token URI or default
138
+ token_uri = self.config.clientTokenUri or "/api/v1/auth/token"
139
+ response = await temp_client.post(token_uri)
140
+ await temp_client.aclose()
141
+
142
+ # Extract correlation ID from response
143
+ correlation_id = self.extract_correlation_id(response)
144
+
145
+ # OpenAPI spec returns 201 (Created) on success, but accept both 200 and 201 for compatibility
146
+ if response.status_code not in [200, 201]:
147
+ error_msg = f"Failed to get client token: HTTP {response.status_code}"
148
+ if client_id:
149
+ error_msg += f" (clientId: {client_id})"
150
+ if correlation_id:
151
+ error_msg += f" (correlationId: {correlation_id})"
152
+ raise AuthenticationError(error_msg, status_code=response.status_code)
153
+
154
+ data = response.json()
155
+
156
+ # Handle nested response structure (data field)
157
+ # If response has {'success': True, 'data': {...}}, extract data and preserve success
158
+ if isinstance(data, dict) and "data" in data and isinstance(data["data"], dict):
159
+ nested_data = data["data"]
160
+ # Merge success from top level if present
161
+ if "success" in data:
162
+ nested_data["success"] = data["success"]
163
+ data = nested_data
164
+
165
+ # Handle controller response format that may not include all fields
166
+ # Controller may return {'token': '...', 'expiresAt': '...'} without success/expiresIn
167
+ if "token" in data:
168
+ # Default success to True if token is present
169
+ if "success" not in data:
170
+ data["success"] = True
171
+ # Calculate expiresIn from expiresAt if missing
172
+ if "expiresIn" not in data and "expiresAt" in data:
173
+ try:
174
+ expires_at = datetime.fromisoformat(
175
+ data["expiresAt"].replace("Z", "+00:00")
176
+ )
177
+ now = (
178
+ datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now()
179
+ )
180
+ expires_in = max(0, int((expires_at - now).total_seconds()))
181
+ data["expiresIn"] = expires_in
182
+ except Exception:
183
+ # If parsing fails, default to 1800 seconds (30 minutes)
184
+ data["expiresIn"] = 1800
185
+
186
+ token_response = ClientTokenResponse(**data)
187
+
188
+ if not token_response.success or not token_response.token:
189
+ error_msg = "Failed to get client token: Invalid response"
190
+ if client_id:
191
+ error_msg += f" (clientId: {client_id})"
192
+ if correlation_id:
193
+ error_msg += f" (correlationId: {correlation_id})"
194
+ raise AuthenticationError(error_msg)
195
+
196
+ self.client_token = token_response.token
197
+
198
+ # Calculate expiration: use expiresIn if available, otherwise decode JWT to get exp claim
199
+ expires_in = token_response.expiresIn
200
+ if not expires_in or expires_in <= 0:
201
+ # Try to extract expiration from JWT token
202
+ try:
203
+ decoded = decode_token(token_response.token)
204
+ if decoded and "exp" in decoded and isinstance(decoded["exp"], (int, float)):
205
+ # Calculate expires_in from JWT exp claim
206
+ token_exp = datetime.fromtimestamp(decoded["exp"])
207
+ now = datetime.now()
208
+ expires_in = max(0, int((token_exp - now).total_seconds()))
209
+ else:
210
+ # No expiration found, use default (30 minutes)
211
+ expires_in = 1800
212
+ except Exception:
213
+ # JWT decode failed, use default (30 minutes)
214
+ expires_in = 1800
215
+
216
+ # Set expiration with 30 second buffer before actual expiration
217
+ expires_in = max(0, expires_in - 30)
218
+ self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
219
+
220
+ except httpx.HTTPError as e:
221
+ error_msg = f"Failed to get client token: {str(e)}"
222
+ if client_id:
223
+ error_msg += f" (clientId: {client_id})"
224
+ if correlation_id:
225
+ error_msg += f" (correlationId: {correlation_id})"
226
+ raise ConnectionError(error_msg)
227
+ except Exception as e:
228
+ if isinstance(e, (AuthenticationError, ConnectionError)):
229
+ raise
230
+ error_msg = f"Failed to get client token: {str(e)}"
231
+ if client_id:
232
+ error_msg += f" (clientId: {client_id})"
233
+ if correlation_id:
234
+ error_msg += f" (correlationId: {correlation_id})"
235
+ raise AuthenticationError(error_msg)
236
+
237
+ def clear_token(self) -> None:
238
+ """
239
+ Clear cached client token.
240
+
241
+ Forces token refresh on next request.
242
+ """
243
+ self.client_token = None
244
+ self.token_expires_at = None
@@ -5,51 +5,68 @@ Automatically loads environment variables with sensible defaults.
5
5
  """
6
6
 
7
7
  import os
8
- from typing import Literal, cast
9
- from ..models.config import MisoClientConfig, RedisConfig
8
+ from typing import List, Literal, cast
9
+
10
10
  from ..errors import ConfigurationError
11
+ from ..models.config import AuthMethod, AuthStrategy, MisoClientConfig, RedisConfig
11
12
 
12
13
 
13
14
  def load_config() -> MisoClientConfig:
14
15
  """
15
16
  Load configuration from environment variables with defaults.
16
-
17
+
17
18
  Required environment variables:
18
19
  - MISO_CONTROLLER_URL (or default to https://controller.aifabrix.ai)
19
20
  - MISO_CLIENTID or MISO_CLIENT_ID
20
21
  - MISO_CLIENTSECRET or MISO_CLIENT_SECRET
21
-
22
+
22
23
  Optional environment variables:
23
24
  - MISO_LOG_LEVEL (debug, info, warn, error)
25
+ - API_KEY (for testing - bypasses OAuth2 authentication)
26
+ - MISO_API_KEY (alternative to API_KEY)
27
+ - MISO_AUTH_STRATEGY (comma-separated list: bearer,client-token,api-key)
28
+ - MISO_CLIENT_TOKEN_URI (custom client token endpoint URI)
29
+ - MISO_ALLOWED_ORIGINS (comma-separated list of allowed origins, supports wildcard ports)
30
+ - MISO_CONTROLLER_URL (maps to controllerPrivateUrl and controller_url for backward compatibility)
31
+ - MISO_WEB_SERVER_URL (maps to controllerPublicUrl for browser/public access)
24
32
  - REDIS_HOST (if Redis is used)
25
33
  - REDIS_PORT (default: 6379)
26
34
  - REDIS_PASSWORD
27
35
  - REDIS_DB (default: 0)
28
36
  - REDIS_KEY_PREFIX (default: miso:)
29
-
37
+
30
38
  Returns:
31
39
  MisoClientConfig instance
32
-
40
+
33
41
  Raises:
34
42
  ConfigurationError: If required environment variables are missing
35
43
  """
36
44
  # Load dotenv if available (similar to TypeScript dotenv/config)
37
45
  try:
38
46
  from dotenv import load_dotenv
47
+
39
48
  load_dotenv()
40
49
  except ImportError:
41
50
  pass # dotenv not installed, continue without it
42
-
51
+
52
+ # MISO_CONTROLLER_URL maps to controllerPrivateUrl (server/internal) and controller_url (backward compatibility)
43
53
  controller_url = os.environ.get("MISO_CONTROLLER_URL") or "https://controller.aifabrix.ai"
44
-
54
+ controller_private_url = os.environ.get(
55
+ "MISO_CONTROLLER_URL"
56
+ ) # Same as controller_url for server
57
+ # MISO_WEB_SERVER_URL maps to controllerPublicUrl (browser/public)
58
+ controller_public_url = os.environ.get("MISO_WEB_SERVER_URL")
59
+
45
60
  client_id = os.environ.get("MISO_CLIENTID") or os.environ.get("MISO_CLIENT_ID") or ""
46
61
  if not client_id:
47
62
  raise ConfigurationError("MISO_CLIENTID environment variable is required")
48
-
49
- client_secret = os.environ.get("MISO_CLIENTSECRET") or os.environ.get("MISO_CLIENT_SECRET") or ""
63
+
64
+ client_secret = (
65
+ os.environ.get("MISO_CLIENTSECRET") or os.environ.get("MISO_CLIENT_SECRET") or ""
66
+ )
50
67
  if not client_secret:
51
68
  raise ConfigurationError("MISO_CLIENTSECRET environment variable is required")
52
-
69
+
53
70
  log_level_str = os.environ.get("MISO_LOG_LEVEL", "info")
54
71
  if log_level_str not in ["debug", "info", "warn", "error"]:
55
72
  log_level_str = "info"
@@ -57,22 +74,77 @@ def load_config() -> MisoClientConfig:
57
74
  log_level: Literal["debug", "info", "warn", "error"] = cast(
58
75
  Literal["debug", "info", "warn", "error"], log_level_str
59
76
  )
60
-
77
+
78
+ # Optional API_KEY for testing (support both API_KEY and MISO_API_KEY)
79
+ api_key = os.environ.get("API_KEY") or os.environ.get("MISO_API_KEY")
80
+
81
+ # Optional auth strategy
82
+ auth_strategy = None
83
+ auth_strategy_str = os.environ.get("MISO_AUTH_STRATEGY")
84
+ if auth_strategy_str:
85
+ try:
86
+ methods_str = [m.strip() for m in auth_strategy_str.split(",")]
87
+ # Validate methods
88
+ valid_methods: List[AuthMethod] = [
89
+ "bearer",
90
+ "client-token",
91
+ "client-credentials",
92
+ "api-key",
93
+ ]
94
+ methods: List[AuthMethod] = []
95
+ for method in methods_str:
96
+ if method in valid_methods:
97
+ methods.append(method) # type: ignore
98
+ else:
99
+ raise ConfigurationError(
100
+ f"Invalid auth method '{method}' in MISO_AUTH_STRATEGY. "
101
+ f"Valid methods: {', '.join(valid_methods)}"
102
+ )
103
+ if methods:
104
+ auth_strategy = AuthStrategy(methods=methods, apiKey=api_key)
105
+ except Exception as e:
106
+ if isinstance(e, ConfigurationError):
107
+ raise
108
+ raise ConfigurationError(f"Failed to parse MISO_AUTH_STRATEGY: {str(e)}")
109
+
110
+ # Optional client token URI
111
+ client_token_uri = os.environ.get("MISO_CLIENT_TOKEN_URI")
112
+
113
+ # Optional allowed origins
114
+ allowed_origins = None
115
+ allowed_origins_str = os.environ.get("MISO_ALLOWED_ORIGINS")
116
+ if allowed_origins_str:
117
+ # Split comma-separated list and trim whitespace
118
+ origins_list = [
119
+ origin.strip() for origin in allowed_origins_str.split(",") if origin.strip()
120
+ ]
121
+ if origins_list:
122
+ allowed_origins = origins_list
123
+
61
124
  config: MisoClientConfig = MisoClientConfig(
62
125
  controller_url=controller_url,
63
126
  client_id=client_id,
64
127
  client_secret=client_secret,
65
128
  log_level=log_level,
129
+ api_key=api_key,
130
+ authStrategy=auth_strategy,
131
+ clientTokenUri=client_token_uri,
132
+ allowedOrigins=allowed_origins,
133
+ controllerPrivateUrl=controller_private_url,
134
+ controllerPublicUrl=controller_public_url,
66
135
  )
67
-
136
+
68
137
  # Optional Redis configuration
69
138
  redis_host = os.environ.get("REDIS_HOST")
70
139
  if redis_host:
71
140
  redis_port = int(os.environ.get("REDIS_PORT", "6379"))
72
141
  redis_password = os.environ.get("REDIS_PASSWORD")
142
+ # Convert empty string to None
143
+ if redis_password == "":
144
+ redis_password = None
73
145
  redis_db = int(os.environ.get("REDIS_DB", "0")) if os.environ.get("REDIS_DB") else 0
74
146
  redis_key_prefix = os.environ.get("REDIS_KEY_PREFIX", "miso:")
75
-
147
+
76
148
  redis_config = RedisConfig(
77
149
  host=redis_host,
78
150
  port=redis_port,
@@ -80,8 +152,7 @@ def load_config() -> MisoClientConfig:
80
152
  db=redis_db,
81
153
  key_prefix=redis_key_prefix,
82
154
  )
83
-
155
+
84
156
  config.redis = redis_config
85
-
86
- return config
87
157
 
158
+ return config
@@ -0,0 +1,80 @@
1
+ """
2
+ Controller URL resolver with environment detection.
3
+
4
+ Automatically selects appropriate controller URL based on environment
5
+ (public for browser, private for server) with fallback support.
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from ..errors import ConfigurationError
11
+ from ..models.config import MisoClientConfig
12
+ from .url_validator import validate_url
13
+
14
+
15
+ def is_browser() -> bool:
16
+ """
17
+ Check if running in browser environment.
18
+
19
+ For Python SDK (server-side only), always returns False.
20
+
21
+ Returns:
22
+ False (Python SDK is server-side only)
23
+ """
24
+ return False
25
+
26
+
27
+ def resolve_controller_url(config: MisoClientConfig) -> str:
28
+ """
29
+ Resolve controller URL based on environment and configuration.
30
+
31
+ For server environment:
32
+ - Uses controllerPrivateUrl if set
33
+ - Falls back to controller_url if controllerPrivateUrl not set
34
+ - Validates resolved URL
35
+ - Raises ConfigurationError if no valid URL found
36
+
37
+ Args:
38
+ config: MisoClient configuration
39
+
40
+ Returns:
41
+ Resolved controller URL string
42
+
43
+ Raises:
44
+ ConfigurationError: If no valid URL is configured
45
+
46
+ Example:
47
+ >>> config = MisoClientConfig(
48
+ ... controller_url="https://controller.example.com",
49
+ ... controllerPrivateUrl="https://controller-private.example.com",
50
+ ... client_id="test",
51
+ ... client_secret="secret"
52
+ ... )
53
+ >>> url = resolve_controller_url(config)
54
+ >>> url
55
+ 'https://controller-private.example.com'
56
+ """
57
+ # Server environment: prefer controllerPrivateUrl, fallback to controller_url
58
+ resolved_url: Optional[str] = None
59
+
60
+ if is_browser():
61
+ # Browser environment (not applicable for Python SDK, but included for completeness)
62
+ resolved_url = config.controllerPublicUrl or config.controller_url
63
+ else:
64
+ # Server environment
65
+ resolved_url = config.controllerPrivateUrl or config.controller_url
66
+
67
+ if not resolved_url:
68
+ raise ConfigurationError(
69
+ "No controller URL configured. Set controller_url, controllerPrivateUrl, "
70
+ "or controllerPublicUrl in MisoClientConfig."
71
+ )
72
+
73
+ # Validate URL
74
+ if not validate_url(resolved_url):
75
+ raise ConfigurationError(
76
+ f"Invalid controller URL format: {resolved_url}. "
77
+ "URL must start with http:// or https:// and have a valid hostname."
78
+ )
79
+
80
+ return resolved_url