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.
- miso_client/__init__.py +523 -130
- miso_client/api/__init__.py +35 -0
- miso_client/api/auth_api.py +367 -0
- miso_client/api/logs_api.py +91 -0
- miso_client/api/permissions_api.py +88 -0
- miso_client/api/roles_api.py +88 -0
- miso_client/api/types/__init__.py +75 -0
- miso_client/api/types/auth_types.py +183 -0
- miso_client/api/types/logs_types.py +71 -0
- miso_client/api/types/permissions_types.py +31 -0
- miso_client/api/types/roles_types.py +31 -0
- miso_client/errors.py +30 -4
- miso_client/models/__init__.py +4 -0
- miso_client/models/config.py +275 -72
- miso_client/models/error_response.py +39 -0
- miso_client/models/filter.py +255 -0
- miso_client/models/pagination.py +44 -0
- miso_client/models/sort.py +25 -0
- miso_client/services/__init__.py +6 -5
- miso_client/services/auth.py +496 -87
- miso_client/services/cache.py +42 -41
- miso_client/services/encryption.py +18 -17
- miso_client/services/logger.py +467 -328
- miso_client/services/logger_chain.py +288 -0
- miso_client/services/permission.py +130 -67
- miso_client/services/redis.py +28 -23
- miso_client/services/role.py +145 -62
- miso_client/utils/__init__.py +3 -3
- miso_client/utils/audit_log_queue.py +222 -0
- miso_client/utils/auth_strategy.py +88 -0
- miso_client/utils/auth_utils.py +65 -0
- miso_client/utils/circuit_breaker.py +125 -0
- miso_client/utils/client_token_manager.py +244 -0
- miso_client/utils/config_loader.py +88 -17
- miso_client/utils/controller_url_resolver.py +80 -0
- miso_client/utils/data_masker.py +104 -33
- miso_client/utils/environment_token.py +126 -0
- miso_client/utils/error_utils.py +216 -0
- miso_client/utils/fastapi_endpoints.py +166 -0
- miso_client/utils/filter.py +364 -0
- miso_client/utils/filter_applier.py +143 -0
- miso_client/utils/filter_parser.py +110 -0
- miso_client/utils/flask_endpoints.py +169 -0
- miso_client/utils/http_client.py +494 -262
- miso_client/utils/http_client_logging.py +352 -0
- miso_client/utils/http_client_logging_helpers.py +197 -0
- miso_client/utils/http_client_query_helpers.py +138 -0
- miso_client/utils/http_error_handler.py +92 -0
- miso_client/utils/http_log_formatter.py +115 -0
- miso_client/utils/http_log_masker.py +203 -0
- miso_client/utils/internal_http_client.py +435 -0
- miso_client/utils/jwt_tools.py +125 -16
- miso_client/utils/logger_helpers.py +206 -0
- miso_client/utils/logging_helpers.py +70 -0
- miso_client/utils/origin_validator.py +128 -0
- miso_client/utils/pagination.py +275 -0
- miso_client/utils/request_context.py +285 -0
- miso_client/utils/sensitive_fields_loader.py +116 -0
- miso_client/utils/sort.py +116 -0
- miso_client/utils/token_utils.py +114 -0
- miso_client/utils/url_validator.py +66 -0
- miso_client/utils/user_token_refresh.py +245 -0
- miso_client-3.7.2.dist-info/METADATA +1021 -0
- miso_client-3.7.2.dist-info/RECORD +68 -0
- miso_client-0.1.0.dist-info/METADATA +0 -551
- miso_client-0.1.0.dist-info/RECORD +0 -23
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/WHEEL +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.1.0.dist-info → miso_client-3.7.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Request context extraction utilities for HTTP requests."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Protocol, Tuple, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from ..utils.jwt_tools import decode_token
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class RequestHeaders(Protocol):
|
|
10
|
+
"""Protocol for request headers access."""
|
|
11
|
+
|
|
12
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
13
|
+
"""Get header value by key."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class RequestClient(Protocol):
|
|
19
|
+
"""Protocol for request client info."""
|
|
20
|
+
|
|
21
|
+
host: Optional[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class RequestURL(Protocol):
|
|
26
|
+
"""Protocol for request URL."""
|
|
27
|
+
|
|
28
|
+
path: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class HttpRequest(Protocol):
|
|
33
|
+
"""
|
|
34
|
+
Protocol for HTTP request objects.
|
|
35
|
+
|
|
36
|
+
Supports:
|
|
37
|
+
- FastAPI/Starlette Request
|
|
38
|
+
- Flask Request
|
|
39
|
+
- Generic dict-like request objects
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
method: str
|
|
43
|
+
headers: RequestHeaders
|
|
44
|
+
client: Optional[RequestClient]
|
|
45
|
+
url: Optional[RequestURL]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RequestContext:
|
|
49
|
+
"""Container for extracted request context."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
ip_address: Optional[str] = None,
|
|
54
|
+
method: Optional[str] = None,
|
|
55
|
+
path: Optional[str] = None,
|
|
56
|
+
user_agent: Optional[str] = None,
|
|
57
|
+
correlation_id: Optional[str] = None,
|
|
58
|
+
referer: Optional[str] = None,
|
|
59
|
+
user_id: Optional[str] = None,
|
|
60
|
+
session_id: Optional[str] = None,
|
|
61
|
+
request_id: Optional[str] = None,
|
|
62
|
+
request_size: Optional[int] = None,
|
|
63
|
+
):
|
|
64
|
+
"""Initialize request context."""
|
|
65
|
+
self.ip_address = ip_address
|
|
66
|
+
self.method = method
|
|
67
|
+
self.path = path
|
|
68
|
+
self.user_agent = user_agent
|
|
69
|
+
self.correlation_id = correlation_id
|
|
70
|
+
self.referer = referer
|
|
71
|
+
self.user_id = user_id
|
|
72
|
+
self.session_id = session_id
|
|
73
|
+
self.request_id = request_id
|
|
74
|
+
self.request_size = request_size
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
77
|
+
"""Convert to dictionary, excluding None values."""
|
|
78
|
+
return {k: v for k, v in self.__dict__.items() if v is not None}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_request_context(request: Any) -> RequestContext:
|
|
82
|
+
"""
|
|
83
|
+
Extract logging context from HTTP request object.
|
|
84
|
+
|
|
85
|
+
Supports multiple Python web frameworks:
|
|
86
|
+
- FastAPI/Starlette Request
|
|
87
|
+
- Flask Request
|
|
88
|
+
- Generic dict-like request objects
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
request: HTTP request object
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
RequestContext with extracted fields
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> from fastapi import Request
|
|
98
|
+
>>> ctx = extract_request_context(request)
|
|
99
|
+
>>> await logger.with_request(request).info("Processing")
|
|
100
|
+
"""
|
|
101
|
+
# Extract IP address (handle proxies)
|
|
102
|
+
ip_address = _extract_ip_address(request)
|
|
103
|
+
|
|
104
|
+
# Extract correlation ID from common headers
|
|
105
|
+
correlation_id = _extract_correlation_id(request)
|
|
106
|
+
|
|
107
|
+
# Extract user from JWT if available
|
|
108
|
+
user_id, session_id = _extract_user_from_auth_header(request)
|
|
109
|
+
|
|
110
|
+
# Extract method
|
|
111
|
+
method = _extract_method(request)
|
|
112
|
+
|
|
113
|
+
# Extract path
|
|
114
|
+
path = _extract_path(request)
|
|
115
|
+
|
|
116
|
+
# Extract other headers
|
|
117
|
+
headers = _get_headers(request)
|
|
118
|
+
user_agent = headers.get("user-agent")
|
|
119
|
+
referer = headers.get("referer")
|
|
120
|
+
request_id = headers.get("x-request-id")
|
|
121
|
+
|
|
122
|
+
# Extract request size
|
|
123
|
+
content_length = headers.get("content-length")
|
|
124
|
+
request_size = int(content_length) if content_length else None
|
|
125
|
+
|
|
126
|
+
return RequestContext(
|
|
127
|
+
ip_address=ip_address,
|
|
128
|
+
method=method,
|
|
129
|
+
path=path,
|
|
130
|
+
user_agent=user_agent,
|
|
131
|
+
correlation_id=correlation_id,
|
|
132
|
+
referer=referer,
|
|
133
|
+
user_id=user_id,
|
|
134
|
+
session_id=session_id,
|
|
135
|
+
request_id=request_id,
|
|
136
|
+
request_size=request_size,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_headers(request: Any) -> Dict[str, Optional[str]]:
|
|
141
|
+
"""Get headers from request object."""
|
|
142
|
+
# FastAPI/Starlette
|
|
143
|
+
if hasattr(request, "headers"):
|
|
144
|
+
headers = request.headers
|
|
145
|
+
if hasattr(headers, "get"):
|
|
146
|
+
# Type cast to satisfy type checker - headers.get() returns Optional[str]
|
|
147
|
+
return headers # type: ignore[no-any-return]
|
|
148
|
+
# Convert to dict if needed
|
|
149
|
+
if hasattr(headers, "items"):
|
|
150
|
+
return dict(headers.items())
|
|
151
|
+
return {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extract_ip_address(request: Any) -> Optional[str]:
|
|
155
|
+
"""Extract client IP address, handling proxies."""
|
|
156
|
+
headers = _get_headers(request)
|
|
157
|
+
|
|
158
|
+
# Check x-forwarded-for first (proxy/load balancer)
|
|
159
|
+
forwarded_for = headers.get("x-forwarded-for")
|
|
160
|
+
if forwarded_for:
|
|
161
|
+
# Take first IP in chain
|
|
162
|
+
return forwarded_for.split(",")[0].strip()
|
|
163
|
+
|
|
164
|
+
# Check x-real-ip
|
|
165
|
+
real_ip = headers.get("x-real-ip")
|
|
166
|
+
if real_ip:
|
|
167
|
+
return real_ip
|
|
168
|
+
|
|
169
|
+
# FastAPI/Starlette: request.client.host
|
|
170
|
+
# Check if client exists and has host attribute with actual value
|
|
171
|
+
if hasattr(request, "client") and request.client:
|
|
172
|
+
try:
|
|
173
|
+
host = getattr(request.client, "host", None)
|
|
174
|
+
# Check if host is a real value (string) and not a MagicMock
|
|
175
|
+
if isinstance(host, str):
|
|
176
|
+
return host
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
# Flask: request.remote_addr
|
|
181
|
+
if hasattr(request, "remote_addr"):
|
|
182
|
+
try:
|
|
183
|
+
remote_addr = getattr(request, "remote_addr", None)
|
|
184
|
+
# Check if remote_addr is a real value (string) and not a MagicMock
|
|
185
|
+
if isinstance(remote_addr, str):
|
|
186
|
+
return remote_addr
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_correlation_id(request: Any) -> Optional[str]:
|
|
194
|
+
"""Extract correlation ID from common headers."""
|
|
195
|
+
headers = _get_headers(request)
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
headers.get("x-correlation-id")
|
|
199
|
+
or headers.get("x-request-id")
|
|
200
|
+
or headers.get("request-id")
|
|
201
|
+
or headers.get("traceparent") # W3C Trace Context
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _extract_method(request: Any) -> Optional[str]:
|
|
206
|
+
"""Extract HTTP method from request."""
|
|
207
|
+
if hasattr(request, "method"):
|
|
208
|
+
try:
|
|
209
|
+
method = getattr(request, "method", None)
|
|
210
|
+
# Check if method is a real value (string) and not a MagicMock
|
|
211
|
+
if isinstance(method, str):
|
|
212
|
+
return method
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _extract_path(request: Any) -> Optional[str]:
|
|
219
|
+
"""Extract request path from request."""
|
|
220
|
+
# FastAPI/Starlette: request.url.path
|
|
221
|
+
if hasattr(request, "url") and request.url:
|
|
222
|
+
try:
|
|
223
|
+
url_path = getattr(request.url, "path", None)
|
|
224
|
+
# Check if path is a real value (string) and not a MagicMock
|
|
225
|
+
if isinstance(url_path, str):
|
|
226
|
+
return url_path
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# Flask: request.path
|
|
231
|
+
if hasattr(request, "path"):
|
|
232
|
+
try:
|
|
233
|
+
path = getattr(request, "path", None)
|
|
234
|
+
# Check if path is a real value (string) and not a MagicMock
|
|
235
|
+
if isinstance(path, str):
|
|
236
|
+
return path
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
# Try original_url (some frameworks)
|
|
241
|
+
if hasattr(request, "original_url"):
|
|
242
|
+
try:
|
|
243
|
+
original_url = getattr(request, "original_url", None)
|
|
244
|
+
# Check if original_url is a real value (string) and not a MagicMock
|
|
245
|
+
if isinstance(original_url, str):
|
|
246
|
+
return original_url
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _extract_user_from_auth_header(request: Any) -> Tuple[Optional[str], Optional[str]]:
|
|
254
|
+
"""
|
|
255
|
+
Extract user ID and session ID from Authorization header JWT.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
request: HTTP request object
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Tuple of (user_id, session_id)
|
|
262
|
+
"""
|
|
263
|
+
headers = _get_headers(request)
|
|
264
|
+
auth_header = headers.get("authorization")
|
|
265
|
+
|
|
266
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
267
|
+
return None, None
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
token = auth_header[7:] # Remove "Bearer " prefix
|
|
271
|
+
decoded = decode_token(token)
|
|
272
|
+
if not decoded:
|
|
273
|
+
return None, None
|
|
274
|
+
|
|
275
|
+
user_id = (
|
|
276
|
+
decoded.get("sub")
|
|
277
|
+
or decoded.get("userId")
|
|
278
|
+
or decoded.get("user_id")
|
|
279
|
+
or decoded.get("id")
|
|
280
|
+
)
|
|
281
|
+
session_id = decoded.get("sessionId") or decoded.get("sid")
|
|
282
|
+
|
|
283
|
+
return user_id, session_id
|
|
284
|
+
except Exception:
|
|
285
|
+
return None, None
|
|
@@ -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 []
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sort utilities for MisoClient SDK.
|
|
3
|
+
|
|
4
|
+
This module provides reusable sort utilities for parsing sort parameters
|
|
5
|
+
and building sort query strings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, cast
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from ..models.sort import SortOption, SortOrder
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_sort_params(params: dict) -> List[SortOption]:
|
|
15
|
+
"""
|
|
16
|
+
Parse sort query parameters into SortOption list.
|
|
17
|
+
|
|
18
|
+
Parses `?sort=-field` format into SortOption objects.
|
|
19
|
+
Supports multiple sort parameters (array of sort strings).
|
|
20
|
+
Prefix with '-' for descending order, otherwise ascending.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
params: Dictionary with query parameters (e.g., {'sort': '-updated_at'} or {'sort': ['-updated_at', 'created_at']})
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of SortOption objects
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> parse_sort_params({'sort': '-updated_at'})
|
|
30
|
+
[SortOption(field='updated_at', order='desc')]
|
|
31
|
+
>>> parse_sort_params({'sort': ['-updated_at', 'created_at']})
|
|
32
|
+
[SortOption(field='updated_at', order='desc'), SortOption(field='created_at', order='asc')]
|
|
33
|
+
"""
|
|
34
|
+
sort_options: List[SortOption] = []
|
|
35
|
+
|
|
36
|
+
# Get sort parameter (can be string or list)
|
|
37
|
+
sort_param = params.get("sort")
|
|
38
|
+
if not sort_param:
|
|
39
|
+
return sort_options
|
|
40
|
+
|
|
41
|
+
# Normalize to list
|
|
42
|
+
if isinstance(sort_param, str):
|
|
43
|
+
sort_strings = [sort_param]
|
|
44
|
+
elif isinstance(sort_param, list):
|
|
45
|
+
sort_strings = sort_param
|
|
46
|
+
else:
|
|
47
|
+
return sort_options
|
|
48
|
+
|
|
49
|
+
# Parse each sort string
|
|
50
|
+
for sort_str in sort_strings:
|
|
51
|
+
if not isinstance(sort_str, str):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
sort_str = sort_str.strip()
|
|
55
|
+
if not sort_str:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Check for descending order (prefix with '-')
|
|
59
|
+
if sort_str.startswith("-"):
|
|
60
|
+
field = sort_str[1:].strip()
|
|
61
|
+
order: SortOrder = "desc"
|
|
62
|
+
else:
|
|
63
|
+
field = sort_str.strip()
|
|
64
|
+
order = "asc"
|
|
65
|
+
|
|
66
|
+
if field:
|
|
67
|
+
sort_options.append(SortOption(field=field, order=cast(SortOrder, order)))
|
|
68
|
+
|
|
69
|
+
return sort_options
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_sort_string(sort_options: List[SortOption]) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Convert SortOption list to query string format.
|
|
75
|
+
|
|
76
|
+
Converts SortOption objects to sort query string format.
|
|
77
|
+
Descending order fields are prefixed with '-'.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
sort_options: List of SortOption objects
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Sort query string (e.g., '-updated_at,created_at' or single value '-updated_at')
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
>>> from miso_client.models.sort import SortOption
|
|
87
|
+
>>> sort_options = [SortOption(field='updated_at', order='desc')]
|
|
88
|
+
>>> build_sort_string(sort_options)
|
|
89
|
+
'-updated_at'
|
|
90
|
+
>>> sort_options = [
|
|
91
|
+
... SortOption(field='updated_at', order='desc'),
|
|
92
|
+
... SortOption(field='created_at', order='asc')
|
|
93
|
+
... ]
|
|
94
|
+
>>> build_sort_string(sort_options)
|
|
95
|
+
'-updated_at,created_at'
|
|
96
|
+
"""
|
|
97
|
+
if not sort_options:
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
sort_strings: List[str] = []
|
|
101
|
+
for sort_option in sort_options:
|
|
102
|
+
field = sort_option.field
|
|
103
|
+
order = sort_option.order
|
|
104
|
+
|
|
105
|
+
# URL encode field name
|
|
106
|
+
field_encoded = quote(field)
|
|
107
|
+
|
|
108
|
+
# Add '-' prefix for descending order
|
|
109
|
+
if order == "desc":
|
|
110
|
+
sort_strings.append(f"-{field_encoded}")
|
|
111
|
+
else:
|
|
112
|
+
sort_strings.append(field_encoded)
|
|
113
|
+
|
|
114
|
+
# Join multiple sorts with comma (if needed for single sort param)
|
|
115
|
+
# Or return as comma-separated string
|
|
116
|
+
return ",".join(sort_strings)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client token utilities for extracting information from JWT tokens.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for decoding client tokens and extracting
|
|
5
|
+
application/environment information without verification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
from .jwt_tools import decode_token
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_client_token_info(client_token: str) -> Dict[str, Optional[str]]:
|
|
14
|
+
"""
|
|
15
|
+
Extract application and environment information from client token.
|
|
16
|
+
|
|
17
|
+
Decodes JWT token without verification (no secret available) and extracts
|
|
18
|
+
fields with fallback support for multiple field name variations.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
client_token: JWT client token string
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dictionary with optional fields:
|
|
25
|
+
- application: Optional[str] - Application name
|
|
26
|
+
- environment: Optional[str] - Environment name
|
|
27
|
+
- applicationId: Optional[str] - Application ID
|
|
28
|
+
- clientId: Optional[str] - Client ID
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
32
|
+
>>> info = extract_client_token_info(token)
|
|
33
|
+
>>> info.get("application")
|
|
34
|
+
'my-app'
|
|
35
|
+
"""
|
|
36
|
+
if not client_token or not isinstance(client_token, str):
|
|
37
|
+
return {
|
|
38
|
+
"application": None,
|
|
39
|
+
"environment": None,
|
|
40
|
+
"applicationId": None,
|
|
41
|
+
"clientId": None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
decoded = decode_token(client_token)
|
|
46
|
+
if not decoded or not isinstance(decoded, dict):
|
|
47
|
+
return {
|
|
48
|
+
"application": None,
|
|
49
|
+
"environment": None,
|
|
50
|
+
"applicationId": None,
|
|
51
|
+
"clientId": None,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Extract fields with fallback support
|
|
55
|
+
application = (
|
|
56
|
+
decoded.get("application")
|
|
57
|
+
or decoded.get("app")
|
|
58
|
+
or decoded.get("Application")
|
|
59
|
+
or decoded.get("App")
|
|
60
|
+
)
|
|
61
|
+
if isinstance(application, str):
|
|
62
|
+
application = application.strip() if application.strip() else None
|
|
63
|
+
else:
|
|
64
|
+
application = None
|
|
65
|
+
|
|
66
|
+
environment = (
|
|
67
|
+
decoded.get("environment")
|
|
68
|
+
or decoded.get("env")
|
|
69
|
+
or decoded.get("Environment")
|
|
70
|
+
or decoded.get("Env")
|
|
71
|
+
)
|
|
72
|
+
if isinstance(environment, str):
|
|
73
|
+
environment = environment.strip() if environment.strip() else None
|
|
74
|
+
else:
|
|
75
|
+
environment = None
|
|
76
|
+
|
|
77
|
+
application_id = (
|
|
78
|
+
decoded.get("applicationId")
|
|
79
|
+
or decoded.get("app_id")
|
|
80
|
+
or decoded.get("application_id")
|
|
81
|
+
or decoded.get("ApplicationId")
|
|
82
|
+
or decoded.get("AppId")
|
|
83
|
+
)
|
|
84
|
+
if isinstance(application_id, str):
|
|
85
|
+
application_id = application_id.strip() if application_id.strip() else None
|
|
86
|
+
else:
|
|
87
|
+
application_id = None
|
|
88
|
+
|
|
89
|
+
client_id = (
|
|
90
|
+
decoded.get("clientId")
|
|
91
|
+
or decoded.get("client_id")
|
|
92
|
+
or decoded.get("ClientId")
|
|
93
|
+
or decoded.get("Client_Id")
|
|
94
|
+
)
|
|
95
|
+
if isinstance(client_id, str):
|
|
96
|
+
client_id = client_id.strip() if client_id.strip() else None
|
|
97
|
+
else:
|
|
98
|
+
client_id = None
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"application": application,
|
|
102
|
+
"environment": environment,
|
|
103
|
+
"applicationId": application_id,
|
|
104
|
+
"clientId": client_id,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
except Exception:
|
|
108
|
+
# Decode failed, return empty dict
|
|
109
|
+
return {
|
|
110
|
+
"application": None,
|
|
111
|
+
"environment": None,
|
|
112
|
+
"applicationId": None,
|
|
113
|
+
"clientId": None,
|
|
114
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL validation utility for controller URLs.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for validating HTTP/HTTPS URLs with comprehensive
|
|
5
|
+
checks to prevent dangerous protocols and ensure valid URL structure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_url(url: str) -> bool:
|
|
12
|
+
"""
|
|
13
|
+
Validate HTTP/HTTPS URL with comprehensive checks.
|
|
14
|
+
|
|
15
|
+
Validates that the URL:
|
|
16
|
+
- Starts with http:// or https://
|
|
17
|
+
- Has a valid hostname
|
|
18
|
+
- Does not use dangerous protocols (javascript:, data:, etc.)
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
url: URL string to validate
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
True if URL is valid, False otherwise
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> validate_url("https://controller.example.com")
|
|
28
|
+
True
|
|
29
|
+
>>> validate_url("javascript:alert('xss')")
|
|
30
|
+
False
|
|
31
|
+
>>> validate_url("http://localhost:3000")
|
|
32
|
+
True
|
|
33
|
+
"""
|
|
34
|
+
if not url or not isinstance(url, str):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
url = url.strip()
|
|
38
|
+
if not url:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
# Check for dangerous protocols
|
|
42
|
+
dangerous_protocols = ["javascript:", "data:", "vbscript:", "file:", "about:"]
|
|
43
|
+
url_lower = url.lower()
|
|
44
|
+
for protocol in dangerous_protocols:
|
|
45
|
+
if url_lower.startswith(protocol):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
# Must start with http:// or https://
|
|
49
|
+
if not url_lower.startswith(("http://", "https://")):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
parsed = urlparse(url)
|
|
54
|
+
# Must have a valid hostname (netloc)
|
|
55
|
+
if not parsed.netloc:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
# Hostname must not be empty
|
|
59
|
+
hostname = parsed.netloc.split(":")[0] # Remove port if present
|
|
60
|
+
if not hostname:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
return True
|
|
64
|
+
except Exception:
|
|
65
|
+
# URL parsing failed
|
|
66
|
+
return False
|