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.
- miso_client/__init__.py +489 -0
- miso_client/errors.py +44 -0
- miso_client/models/__init__.py +1 -0
- miso_client/models/config.py +174 -0
- miso_client/py.typed +0 -0
- miso_client/services/__init__.py +20 -0
- miso_client/services/auth.py +160 -0
- miso_client/services/cache.py +204 -0
- miso_client/services/encryption.py +93 -0
- miso_client/services/logger.py +457 -0
- miso_client/services/permission.py +208 -0
- miso_client/services/redis.py +179 -0
- miso_client/services/role.py +180 -0
- miso_client/utils/__init__.py +15 -0
- miso_client/utils/config_loader.py +87 -0
- miso_client/utils/data_masker.py +156 -0
- miso_client/utils/http_client.py +377 -0
- miso_client/utils/jwt_tools.py +78 -0
- miso_client-0.1.0.dist-info/METADATA +551 -0
- miso_client-0.1.0.dist-info/RECORD +23 -0
- miso_client-0.1.0.dist-info/WHEEL +5 -0
- miso_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- miso_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|