miso-client 0.1.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.
@@ -0,0 +1,457 @@
1
+ """
2
+ Logger service for application logging and audit events.
3
+
4
+ This module provides structured logging with Redis queuing and HTTP fallback.
5
+ Includes JWT context extraction, data masking, correlation IDs, and performance metrics.
6
+ """
7
+
8
+ import os
9
+ import random
10
+ import sys
11
+ from datetime import datetime
12
+ from typing import Optional, Dict, Any, Literal
13
+ from ..models.config import (
14
+ LogEntry,
15
+ ClientLoggingOptions
16
+ )
17
+ from ..services.redis import RedisService
18
+ from ..utils.http_client import HttpClient
19
+ from ..utils.data_masker import DataMasker
20
+ from ..utils.jwt_tools import decode_token
21
+
22
+
23
+ class LoggerService:
24
+ """Logger service for application logging and audit events."""
25
+
26
+ def __init__(self, http_client: HttpClient, redis: RedisService):
27
+ """
28
+ Initialize logger service.
29
+
30
+ Args:
31
+ http_client: HTTP client instance
32
+ redis: Redis service instance
33
+ """
34
+ self.config = http_client.config
35
+ self.http_client = http_client
36
+ self.redis = redis
37
+ self.mask_sensitive_data = True # Default: mask sensitive data
38
+ self.correlation_counter = 0
39
+ self.performance_metrics: Dict[str, Dict[str, Any]] = {}
40
+
41
+ def set_masking(self, enabled: bool) -> None:
42
+ """
43
+ Enable or disable sensitive data masking.
44
+
45
+ Args:
46
+ enabled: Whether to enable data masking
47
+ """
48
+ self.mask_sensitive_data = enabled
49
+
50
+ def _generate_correlation_id(self) -> str:
51
+ """
52
+ Generate unique correlation ID for request tracking.
53
+
54
+ Format: {clientId[0:10]}-{timestamp}-{counter}-{random}
55
+
56
+ Returns:
57
+ Correlation ID string
58
+ """
59
+ self.correlation_counter = (self.correlation_counter + 1) % 10000
60
+ timestamp = int(datetime.now().timestamp() * 1000)
61
+ random_part = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=6))
62
+ client_prefix = self.config.client_id[:10] if len(self.config.client_id) > 10 else self.config.client_id
63
+ return f"{client_prefix}-{timestamp}-{self.correlation_counter}-{random_part}"
64
+
65
+ def _extract_jwt_context(self, token: Optional[str]) -> Dict[str, Any]:
66
+ """
67
+ Extract JWT token information.
68
+
69
+ Args:
70
+ token: JWT token string
71
+
72
+ Returns:
73
+ Dictionary with userId, applicationId, sessionId, roles, permissions
74
+ """
75
+ if not token:
76
+ return {}
77
+
78
+ try:
79
+ decoded = decode_token(token)
80
+ if not decoded:
81
+ return {}
82
+
83
+ # Extract roles - handle different formats
84
+ roles = []
85
+ if "roles" in decoded:
86
+ roles = decoded["roles"] if isinstance(decoded["roles"], list) else []
87
+ elif "realm_access" in decoded and isinstance(decoded["realm_access"], dict):
88
+ roles = decoded["realm_access"].get("roles", [])
89
+
90
+ # Extract permissions - handle different formats
91
+ permissions = []
92
+ if "permissions" in decoded:
93
+ permissions = decoded["permissions"] if isinstance(decoded["permissions"], list) else []
94
+ elif "scope" in decoded and isinstance(decoded["scope"], str):
95
+ permissions = decoded["scope"].split()
96
+
97
+ return {
98
+ "userId": decoded.get("sub") or decoded.get("userId") or decoded.get("user_id"),
99
+ "applicationId": decoded.get("applicationId") or decoded.get("app_id"),
100
+ "sessionId": decoded.get("sessionId") or decoded.get("sid"),
101
+ "roles": roles,
102
+ "permissions": permissions,
103
+ }
104
+ except Exception:
105
+ # JWT parsing failed, return empty context
106
+ return {}
107
+
108
+ def _extract_metadata(self) -> Dict[str, Any]:
109
+ """
110
+ Extract metadata from environment (browser or Node.js).
111
+
112
+ Returns:
113
+ Dictionary with hostname, userAgent, etc.
114
+ """
115
+ metadata: Dict[str, Any] = {}
116
+
117
+ # Try to extract Node.js/Python metadata
118
+ if hasattr(os, "environ"):
119
+ metadata["hostname"] = os.environ.get("HOSTNAME", "unknown")
120
+
121
+ # In Python, we don't have browser metadata like in TypeScript
122
+ # But we can capture some environment info
123
+ metadata["platform"] = sys.platform
124
+ metadata["python_version"] = sys.version
125
+
126
+ return metadata
127
+
128
+ def start_performance_tracking(self, operation_id: str) -> None:
129
+ """
130
+ Start performance tracking.
131
+
132
+ Args:
133
+ operation_id: Unique identifier for this operation
134
+ """
135
+ try:
136
+ import psutil
137
+ process = psutil.Process()
138
+ memory_info = process.memory_info()
139
+ memory_usage = {
140
+ "rss": memory_info.rss,
141
+ "heapTotal": memory_info.rss, # Approximation
142
+ "heapUsed": memory_info.rss - memory_info.available if hasattr(memory_info, "available") else memory_info.rss,
143
+ "external": 0,
144
+ "arrayBuffers": 0,
145
+ }
146
+ except ImportError:
147
+ # psutil not available
148
+ memory_usage = None
149
+
150
+ self.performance_metrics[operation_id] = {
151
+ "startTime": int(datetime.now().timestamp() * 1000),
152
+ "memoryUsage": memory_usage,
153
+ }
154
+
155
+ def end_performance_tracking(self, operation_id: str) -> Optional[Dict[str, Any]]:
156
+ """
157
+ End performance tracking and get metrics.
158
+
159
+ Args:
160
+ operation_id: Unique identifier for this operation
161
+
162
+ Returns:
163
+ Performance metrics dictionary or None if not found
164
+ """
165
+ if operation_id not in self.performance_metrics:
166
+ return None
167
+
168
+ metrics = self.performance_metrics[operation_id]
169
+ metrics["endTime"] = int(datetime.now().timestamp() * 1000)
170
+ metrics["duration"] = metrics["endTime"] - metrics["startTime"]
171
+
172
+ try:
173
+ import psutil
174
+ process = psutil.Process()
175
+ memory_info = process.memory_info()
176
+ metrics["memoryUsage"] = {
177
+ "rss": memory_info.rss,
178
+ "heapTotal": memory_info.rss,
179
+ "heapUsed": memory_info.rss - memory_info.available if hasattr(memory_info, "available") else memory_info.rss,
180
+ "external": 0,
181
+ "arrayBuffers": 0,
182
+ }
183
+ except (ImportError, Exception):
184
+ pass # psutil not available or error getting memory info
185
+
186
+ del self.performance_metrics[operation_id]
187
+ return metrics
188
+
189
+ async def error(
190
+ self,
191
+ message: str,
192
+ context: Optional[Dict[str, Any]] = None,
193
+ stack_trace: Optional[str] = None,
194
+ options: Optional[ClientLoggingOptions] = None
195
+ ) -> None:
196
+ """
197
+ Log error message with optional stack trace and enhanced options.
198
+
199
+ Args:
200
+ message: Error message
201
+ context: Additional context data
202
+ stack_trace: Stack trace string
203
+ options: Logging options
204
+ """
205
+ await self._log("error", message, context, stack_trace, options)
206
+
207
+ async def audit(
208
+ self,
209
+ action: str,
210
+ resource: str,
211
+ context: Optional[Dict[str, Any]] = None,
212
+ options: Optional[ClientLoggingOptions] = None
213
+ ) -> None:
214
+ """
215
+ Log audit event with enhanced options.
216
+
217
+ Args:
218
+ action: Action performed
219
+ resource: Resource affected
220
+ context: Additional context data
221
+ options: Logging options
222
+ """
223
+ audit_context = {
224
+ "action": action,
225
+ "resource": resource,
226
+ **(context or {})
227
+ }
228
+ await self._log("audit", f"Audit: {action} on {resource}", audit_context, None, options)
229
+
230
+ async def info(
231
+ self,
232
+ message: str,
233
+ context: Optional[Dict[str, Any]] = None,
234
+ options: Optional[ClientLoggingOptions] = None
235
+ ) -> None:
236
+ """
237
+ Log info message with enhanced options.
238
+
239
+ Args:
240
+ message: Info message
241
+ context: Additional context data
242
+ options: Logging options
243
+ """
244
+ await self._log("info", message, context, None, options)
245
+
246
+ async def debug(
247
+ self,
248
+ message: str,
249
+ context: Optional[Dict[str, Any]] = None,
250
+ options: Optional[ClientLoggingOptions] = None
251
+ ) -> None:
252
+ """
253
+ Log debug message with enhanced options.
254
+
255
+ Args:
256
+ message: Debug message
257
+ context: Additional context data
258
+ options: Logging options
259
+ """
260
+ if self.config.log_level == "debug":
261
+ await self._log("debug", message, context, None, options)
262
+
263
+ async def _log(
264
+ self,
265
+ level: Literal["error", "audit", "info", "debug"],
266
+ message: str,
267
+ context: Optional[Dict[str, Any]] = None,
268
+ stack_trace: Optional[str] = None,
269
+ options: Optional[ClientLoggingOptions] = None
270
+ ) -> None:
271
+ """
272
+ Internal log method with enhanced features.
273
+
274
+ Args:
275
+ level: Log level
276
+ message: Log message
277
+ context: Additional context data
278
+ stack_trace: Stack trace for errors
279
+ options: Logging options
280
+ """
281
+ # Extract JWT context if token provided
282
+ jwt_context = self._extract_jwt_context(options.token if options else None) if options else {}
283
+
284
+ # Extract environment metadata
285
+ metadata = self._extract_metadata()
286
+
287
+ # Generate correlation ID if not provided
288
+ correlation_id = (options.correlationId if options else None) or self._generate_correlation_id()
289
+
290
+ # Mask sensitive data in context if enabled
291
+ mask_sensitive = (options.maskSensitiveData if options else None) is not False and self.mask_sensitive_data
292
+ masked_context = (
293
+ DataMasker.mask_sensitive_data(context) if mask_sensitive and context
294
+ else context
295
+ )
296
+
297
+ # Add performance metrics if requested
298
+ enhanced_context = masked_context
299
+ if options and options.performanceMetrics:
300
+ try:
301
+ import psutil
302
+ process = psutil.Process()
303
+ memory_info = process.memory_info()
304
+ enhanced_context = {
305
+ **(enhanced_context or {}),
306
+ "performance": {
307
+ "memoryUsage": {
308
+ "rss": memory_info.rss,
309
+ "heapTotal": memory_info.rss,
310
+ "heapUsed": memory_info.rss - memory_info.available if hasattr(memory_info, "available") else memory_info.rss,
311
+ },
312
+ "uptime": psutil.boot_time() if hasattr(psutil, "boot_time") else 0,
313
+ }
314
+ }
315
+ except (ImportError, Exception):
316
+ pass # psutil not available or error getting memory info
317
+
318
+ log_entry_data = {
319
+ "timestamp": datetime.utcnow().isoformat(),
320
+ "level": level,
321
+ "environment": "unknown", # Backend extracts from client credentials
322
+ "application": self.config.client_id, # Use clientId as application identifier
323
+ "applicationId": options.applicationId if options else None,
324
+ "message": message,
325
+ "context": enhanced_context,
326
+ "stackTrace": stack_trace,
327
+ "correlationId": correlation_id,
328
+ "userId": (options.userId if options else None) or jwt_context.get("userId"),
329
+ "sessionId": (options.sessionId if options else None) or jwt_context.get("sessionId"),
330
+ "requestId": options.requestId if options else None,
331
+ **metadata
332
+ }
333
+
334
+ # Remove None values
335
+ log_entry_data = {k: v for k, v in log_entry_data.items() if v is not None}
336
+
337
+ log_entry = LogEntry(**log_entry_data)
338
+
339
+ # Try Redis first (if available)
340
+ if self.redis.is_connected():
341
+ queue_name = f"logs:{self.config.client_id}"
342
+ success = await self.redis.rpush(queue_name, log_entry.model_dump_json())
343
+
344
+ if success:
345
+ return # Successfully queued in Redis
346
+
347
+ # Fallback to unified logging endpoint with client credentials
348
+ try:
349
+ # Backend extracts environment and application from client credentials
350
+ log_payload = log_entry.model_dump(exclude={"environment", "application"}, exclude_none=True)
351
+ await self.http_client.request("POST", "/api/logs", log_payload)
352
+ except Exception:
353
+ # Failed to send log to controller
354
+ # Silently fail to avoid infinite logging loops
355
+ # Application should implement retry or buffer strategy if needed
356
+ pass
357
+
358
+ def with_context(self, context: Dict[str, Any]) -> "LoggerChain":
359
+ """Create logger chain with context."""
360
+ return LoggerChain(self, context, ClientLoggingOptions())
361
+
362
+ def with_token(self, token: str) -> "LoggerChain":
363
+ """Create logger chain with token."""
364
+ return LoggerChain(self, {}, ClientLoggingOptions(token=token))
365
+
366
+ def with_performance(self) -> "LoggerChain":
367
+ """Create logger chain with performance metrics."""
368
+ opts = ClientLoggingOptions()
369
+ opts.performanceMetrics = True
370
+ return LoggerChain(self, {}, opts)
371
+
372
+ def without_masking(self) -> "LoggerChain":
373
+ """Create logger chain without data masking."""
374
+ opts = ClientLoggingOptions()
375
+ opts.maskSensitiveData = False
376
+ return LoggerChain(self, {}, opts)
377
+
378
+
379
+ class LoggerChain:
380
+ """Method chaining class for fluent logging API."""
381
+
382
+ def __init__(
383
+ self,
384
+ logger: LoggerService,
385
+ context: Optional[Dict[str, Any]] = None,
386
+ options: Optional[ClientLoggingOptions] = None
387
+ ):
388
+ """
389
+ Initialize logger chain.
390
+
391
+ Args:
392
+ logger: Logger service instance
393
+ context: Initial context
394
+ options: Initial logging options
395
+ """
396
+ self.logger = logger
397
+ self.context = context or {}
398
+ self.options = options or ClientLoggingOptions()
399
+
400
+ def add_context(self, key: str, value: Any) -> "LoggerChain":
401
+ """Add context key-value pair."""
402
+ self.context[key] = value
403
+ return self
404
+
405
+ def add_user(self, user_id: str) -> "LoggerChain":
406
+ """Add user ID."""
407
+ if self.options is None:
408
+ self.options = ClientLoggingOptions()
409
+ self.options.userId = user_id
410
+ return self
411
+
412
+ def add_application(self, application_id: str) -> "LoggerChain":
413
+ """Add application ID."""
414
+ if self.options is None:
415
+ self.options = ClientLoggingOptions()
416
+ self.options.applicationId = application_id
417
+ return self
418
+
419
+ def add_correlation(self, correlation_id: str) -> "LoggerChain":
420
+ """Add correlation ID."""
421
+ if self.options is None:
422
+ self.options = ClientLoggingOptions()
423
+ self.options.correlationId = correlation_id
424
+ return self
425
+
426
+ def with_token(self, token: str) -> "LoggerChain":
427
+ """Add token for context extraction."""
428
+ if self.options is None:
429
+ self.options = ClientLoggingOptions()
430
+ self.options.token = token
431
+ return self
432
+
433
+ def with_performance(self) -> "LoggerChain":
434
+ """Enable performance metrics."""
435
+ if self.options is None:
436
+ self.options = ClientLoggingOptions()
437
+ self.options.performanceMetrics = True
438
+ return self
439
+
440
+ def without_masking(self) -> "LoggerChain":
441
+ """Disable data masking."""
442
+ if self.options is None:
443
+ self.options = ClientLoggingOptions()
444
+ self.options.maskSensitiveData = False
445
+ return self
446
+
447
+ async def error(self, message: str, stack_trace: Optional[str] = None) -> None:
448
+ """Log error."""
449
+ await self.logger.error(message, self.context, stack_trace, self.options)
450
+
451
+ async def info(self, message: str) -> None:
452
+ """Log info."""
453
+ await self.logger.info(message, self.context, self.options)
454
+
455
+ async def audit(self, action: str, resource: str) -> None:
456
+ """Log audit."""
457
+ await self.logger.audit(action, resource, self.context, self.options)
@@ -0,0 +1,208 @@
1
+ """
2
+ Permission service for user authorization with caching.
3
+
4
+ This module handles permission-based access control with caching support.
5
+ Permissions are cached with Redis and in-memory fallback using CacheService.
6
+ Optimized to extract userId from JWT token before API calls for cache optimization.
7
+ """
8
+
9
+ import time
10
+ from typing import List, cast
11
+ from ..models.config import PermissionResult
12
+ from ..services.cache import CacheService
13
+ from ..utils.http_client import HttpClient
14
+ from ..utils.jwt_tools import extract_user_id
15
+
16
+
17
+ class PermissionService:
18
+ """Permission service for user authorization with caching."""
19
+
20
+ def __init__(self, http_client: HttpClient, cache: CacheService):
21
+ """
22
+ Initialize permission service.
23
+
24
+ Args:
25
+ http_client: HTTP client instance
26
+ cache: Cache service instance (handles Redis + in-memory fallback)
27
+ """
28
+ self.config = http_client.config
29
+ self.http_client = http_client
30
+ self.cache = cache
31
+ self.permission_ttl = self.config.permission_ttl
32
+
33
+ async def get_permissions(self, token: str) -> List[str]:
34
+ """
35
+ Get user permissions with Redis caching.
36
+
37
+ Optimized to extract userId from token first to check cache before API call.
38
+
39
+ Args:
40
+ token: JWT token
41
+
42
+ Returns:
43
+ List of user permissions
44
+ """
45
+ try:
46
+ # Extract userId from token to check cache first (avoids API call on cache hit)
47
+ user_id = extract_user_id(token)
48
+ cache_key = f"permissions:{user_id}" if user_id else None
49
+
50
+ # Check cache first if we have userId
51
+ if cache_key:
52
+ cached_data = await self.cache.get(cache_key)
53
+ if cached_data and isinstance(cached_data, dict):
54
+ return cast(List[str], cached_data.get("permissions", []))
55
+
56
+ # Cache miss or no userId in token - fetch from controller
57
+ # If we don't have userId, get it from validate endpoint
58
+ if not user_id:
59
+ user_info = await self.http_client.authenticated_request(
60
+ "POST",
61
+ "/api/auth/validate",
62
+ token
63
+ )
64
+ user_id = user_info.get("user", {}).get("id") if user_info else None
65
+ if not user_id:
66
+ return []
67
+ cache_key = f"permissions:{user_id}"
68
+
69
+ # Cache miss - fetch from controller
70
+ permission_result = await self.http_client.authenticated_request(
71
+ "GET",
72
+ "/api/auth/permissions", # Backend knows app/env from client token
73
+ token
74
+ )
75
+
76
+ permission_data = PermissionResult(**permission_result)
77
+ permissions = permission_data.permissions or []
78
+
79
+ # Cache the result (CacheService handles Redis + in-memory automatically)
80
+ assert cache_key is not None
81
+ await self.cache.set(
82
+ cache_key,
83
+ {"permissions": permissions, "timestamp": int(time.time() * 1000)},
84
+ self.permission_ttl
85
+ )
86
+
87
+ return permissions
88
+
89
+ except Exception:
90
+ # Failed to get permissions, return empty list
91
+ return []
92
+
93
+ async def has_permission(self, token: str, permission: str) -> bool:
94
+ """
95
+ Check if user has specific permission.
96
+
97
+ Args:
98
+ token: JWT token
99
+ permission: Permission to check
100
+
101
+ Returns:
102
+ True if user has the permission, False otherwise
103
+ """
104
+ permissions = await self.get_permissions(token)
105
+ return permission in permissions
106
+
107
+ async def has_any_permission(self, token: str, permissions: List[str]) -> bool:
108
+ """
109
+ Check if user has any of the specified permissions.
110
+
111
+ Args:
112
+ token: JWT token
113
+ permissions: List of permissions to check
114
+
115
+ Returns:
116
+ True if user has any of the permissions, False otherwise
117
+ """
118
+ user_permissions = await self.get_permissions(token)
119
+ return any(permission in user_permissions for permission in permissions)
120
+
121
+ async def has_all_permissions(self, token: str, permissions: List[str]) -> bool:
122
+ """
123
+ Check if user has all of the specified permissions.
124
+
125
+ Args:
126
+ token: JWT token
127
+ permissions: List of permissions to check
128
+
129
+ Returns:
130
+ True if user has all permissions, False otherwise
131
+ """
132
+ user_permissions = await self.get_permissions(token)
133
+ return all(permission in user_permissions for permission in permissions)
134
+
135
+ async def refresh_permissions(self, token: str) -> List[str]:
136
+ """
137
+ Force refresh permissions from controller (bypass cache).
138
+
139
+ Args:
140
+ token: JWT token
141
+
142
+ Returns:
143
+ Fresh list of user permissions
144
+ """
145
+ try:
146
+ # Get user info to extract userId
147
+ user_info = await self.http_client.authenticated_request(
148
+ "POST",
149
+ "/api/auth/validate",
150
+ token
151
+ )
152
+
153
+ user_id = user_info.get("user", {}).get("id") if user_info else None
154
+ if not user_id:
155
+ return []
156
+
157
+ cache_key = f"permissions:{user_id}"
158
+
159
+ # Fetch fresh permissions from controller using refresh endpoint
160
+ permission_result = await self.http_client.authenticated_request(
161
+ "GET",
162
+ "/api/auth/permissions/refresh",
163
+ token
164
+ )
165
+
166
+ permission_data = PermissionResult(**permission_result)
167
+ permissions = permission_data.permissions or []
168
+
169
+ # Update cache with fresh data (CacheService handles Redis + in-memory automatically)
170
+ await self.cache.set(
171
+ cache_key,
172
+ {"permissions": permissions, "timestamp": int(time.time() * 1000)},
173
+ self.permission_ttl
174
+ )
175
+
176
+ return permissions
177
+
178
+ except Exception:
179
+ # Failed to refresh permissions, return empty list
180
+ return []
181
+
182
+ async def clear_permissions_cache(self, token: str) -> None:
183
+ """
184
+ Clear cached permissions for a user.
185
+
186
+ Args:
187
+ token: JWT token
188
+ """
189
+ try:
190
+ # Get user info to extract userId
191
+ user_info = await self.http_client.authenticated_request(
192
+ "POST",
193
+ "/api/auth/validate",
194
+ token
195
+ )
196
+
197
+ user_id = user_info.get("user", {}).get("id") if user_info else None
198
+ if not user_id:
199
+ return
200
+
201
+ cache_key = f"permissions:{user_id}"
202
+
203
+ # Clear from cache (CacheService handles Redis + in-memory automatically)
204
+ await self.cache.delete(cache_key)
205
+
206
+ except Exception:
207
+ # Failed to clear cache, silently continue
208
+ pass