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,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration types for MisoClient SDK.
|
|
3
|
+
|
|
4
|
+
This module contains Pydantic models that define the configuration structure
|
|
5
|
+
and data types used throughout the MisoClient SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional, Dict, Any, List, Literal
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RedisConfig(BaseModel):
|
|
13
|
+
"""Redis connection configuration."""
|
|
14
|
+
|
|
15
|
+
host: str = Field(..., description="Redis host")
|
|
16
|
+
port: int = Field(default=6379, description="Redis port")
|
|
17
|
+
password: Optional[str] = Field(default=None, description="Redis password")
|
|
18
|
+
db: int = Field(default=0, description="Redis database number")
|
|
19
|
+
key_prefix: str = Field(default="miso:", description="Key prefix for Redis keys")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MisoClientConfig(BaseModel):
|
|
23
|
+
"""Main MisoClient configuration.
|
|
24
|
+
|
|
25
|
+
Required fields:
|
|
26
|
+
- controller_url: Miso Controller base URL
|
|
27
|
+
- client_id: Client identifier for authentication
|
|
28
|
+
- client_secret: Client secret for authentication
|
|
29
|
+
|
|
30
|
+
Optional fields:
|
|
31
|
+
- redis: Redis configuration for caching
|
|
32
|
+
- log_level: Logging level (debug, info, warn, error)
|
|
33
|
+
- cache: Cache TTL settings for roles and permissions
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
controller_url: str = Field(..., description="Miso Controller base URL")
|
|
37
|
+
client_id: str = Field(..., description="Client identifier for authentication")
|
|
38
|
+
client_secret: str = Field(..., description="Client secret for authentication")
|
|
39
|
+
redis: Optional[RedisConfig] = Field(default=None, description="Optional Redis configuration")
|
|
40
|
+
log_level: Literal["debug", "info", "warn", "error"] = Field(
|
|
41
|
+
default="info",
|
|
42
|
+
description="Log level"
|
|
43
|
+
)
|
|
44
|
+
cache: Optional[Dict[str, int]] = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="Cache TTL settings: permission_ttl, role_ttl (default: 900 seconds)"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def role_ttl(self) -> int:
|
|
51
|
+
"""Get role cache TTL in seconds."""
|
|
52
|
+
if self.cache and "role_ttl" in self.cache:
|
|
53
|
+
return self.cache["role_ttl"]
|
|
54
|
+
return self.cache.get("roleTTL", 900) if self.cache else 900 # 15 minutes default
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def permission_ttl(self) -> int:
|
|
58
|
+
"""Get permission cache TTL in seconds."""
|
|
59
|
+
if self.cache and "permission_ttl" in self.cache:
|
|
60
|
+
return self.cache["permission_ttl"]
|
|
61
|
+
return self.cache.get("permissionTTL", 900) if self.cache else 900 # 15 minutes default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UserInfo(BaseModel):
|
|
65
|
+
"""User information from token validation."""
|
|
66
|
+
|
|
67
|
+
id: str = Field(..., description="User ID")
|
|
68
|
+
username: str = Field(..., description="Username")
|
|
69
|
+
email: Optional[str] = Field(default=None, description="User email")
|
|
70
|
+
firstName: Optional[str] = Field(default=None, alias="first_name", description="First name")
|
|
71
|
+
lastName: Optional[str] = Field(default=None, alias="last_name", description="Last name")
|
|
72
|
+
roles: Optional[List[str]] = Field(default=None, description="User roles")
|
|
73
|
+
|
|
74
|
+
class Config:
|
|
75
|
+
populate_by_name = True # Allow both snake_case and camelCase
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AuthResult(BaseModel):
|
|
79
|
+
"""Authentication result."""
|
|
80
|
+
|
|
81
|
+
authenticated: bool = Field(..., description="Whether authentication was successful")
|
|
82
|
+
user: Optional[UserInfo] = Field(default=None, description="User information if authenticated")
|
|
83
|
+
error: Optional[str] = Field(default=None, description="Error message if authentication failed")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LogEntry(BaseModel):
|
|
87
|
+
"""Log entry structure."""
|
|
88
|
+
|
|
89
|
+
timestamp: str = Field(..., description="ISO timestamp")
|
|
90
|
+
level: Literal["error", "audit", "info", "debug"] = Field(..., description="Log level")
|
|
91
|
+
environment: str = Field(..., description="Environment name (extracted by backend)")
|
|
92
|
+
application: str = Field(..., description="Application identifier (clientId)")
|
|
93
|
+
applicationId: Optional[str] = Field(default=None, alias="application_id", description="Application ID")
|
|
94
|
+
userId: Optional[str] = Field(default=None, alias="user_id", description="User ID if available")
|
|
95
|
+
message: str = Field(..., description="Log message")
|
|
96
|
+
context: Optional[Dict[str, Any]] = Field(default=None, description="Additional context")
|
|
97
|
+
correlationId: Optional[str] = Field(default=None, alias="correlation_id", description="Correlation ID for tracing")
|
|
98
|
+
requestId: Optional[str] = Field(default=None, alias="request_id", description="Request ID")
|
|
99
|
+
sessionId: Optional[str] = Field(default=None, alias="session_id", description="Session ID")
|
|
100
|
+
stackTrace: Optional[str] = Field(default=None, alias="stack_trace", description="Stack trace for errors")
|
|
101
|
+
ipAddress: Optional[str] = Field(default=None, alias="ip_address", description="IP address")
|
|
102
|
+
userAgent: Optional[str] = Field(default=None, alias="user_agent", description="User agent")
|
|
103
|
+
hostname: Optional[str] = Field(default=None, description="Hostname")
|
|
104
|
+
|
|
105
|
+
class Config:
|
|
106
|
+
populate_by_name = True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RoleResult(BaseModel):
|
|
110
|
+
"""Role query result."""
|
|
111
|
+
|
|
112
|
+
userId: str = Field(..., alias="user_id", description="User ID")
|
|
113
|
+
roles: List[str] = Field(..., description="List of user roles")
|
|
114
|
+
environment: str = Field(..., description="Environment name")
|
|
115
|
+
application: str = Field(..., description="Application name")
|
|
116
|
+
|
|
117
|
+
class Config:
|
|
118
|
+
populate_by_name = True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PermissionResult(BaseModel):
|
|
122
|
+
"""Permission query result."""
|
|
123
|
+
|
|
124
|
+
userId: str = Field(..., alias="user_id", description="User ID")
|
|
125
|
+
permissions: List[str] = Field(..., description="List of user permissions")
|
|
126
|
+
environment: str = Field(..., description="Environment name")
|
|
127
|
+
application: str = Field(..., description="Application name")
|
|
128
|
+
|
|
129
|
+
class Config:
|
|
130
|
+
populate_by_name = True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ClientTokenResponse(BaseModel):
|
|
134
|
+
"""Client token response."""
|
|
135
|
+
|
|
136
|
+
success: bool = Field(..., description="Whether token request was successful")
|
|
137
|
+
token: str = Field(..., description="Client token")
|
|
138
|
+
expiresIn: int = Field(..., alias="expires_in", description="Token expiration in seconds")
|
|
139
|
+
expiresAt: str = Field(..., alias="expires_at", description="Token expiration ISO timestamp")
|
|
140
|
+
|
|
141
|
+
class Config:
|
|
142
|
+
populate_by_name = True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PerformanceMetrics(BaseModel):
|
|
146
|
+
"""Performance metrics for logging."""
|
|
147
|
+
|
|
148
|
+
startTime: int = Field(..., alias="start_time", description="Start time in milliseconds")
|
|
149
|
+
endTime: Optional[int] = Field(default=None, alias="end_time", description="End time in milliseconds")
|
|
150
|
+
duration: Optional[int] = Field(default=None, description="Duration in milliseconds")
|
|
151
|
+
memoryUsage: Optional[Dict[str, int]] = Field(
|
|
152
|
+
default=None,
|
|
153
|
+
alias="memory_usage",
|
|
154
|
+
description="Memory usage metrics (rss, heapTotal, heapUsed, external, arrayBuffers)"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
class Config:
|
|
158
|
+
populate_by_name = True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ClientLoggingOptions(BaseModel):
|
|
162
|
+
"""Options for client logging."""
|
|
163
|
+
|
|
164
|
+
applicationId: Optional[str] = Field(default=None, alias="application_id", description="Application ID")
|
|
165
|
+
userId: Optional[str] = Field(default=None, alias="user_id", description="User ID")
|
|
166
|
+
correlationId: Optional[str] = Field(default=None, alias="correlation_id", description="Correlation ID")
|
|
167
|
+
requestId: Optional[str] = Field(default=None, alias="request_id", description="Request ID")
|
|
168
|
+
sessionId: Optional[str] = Field(default=None, alias="session_id", description="Session ID")
|
|
169
|
+
token: Optional[str] = Field(default=None, description="JWT token for context extraction")
|
|
170
|
+
maskSensitiveData: Optional[bool] = Field(default=None, alias="mask_sensitive_data", description="Enable data masking")
|
|
171
|
+
performanceMetrics: Optional[bool] = Field(default=None, alias="performance_metrics", description="Include performance metrics")
|
|
172
|
+
|
|
173
|
+
class Config:
|
|
174
|
+
populate_by_name = True
|
miso_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Service implementations for MisoClient SDK."""
|
|
2
|
+
|
|
3
|
+
from .auth import AuthService
|
|
4
|
+
from .role import RoleService
|
|
5
|
+
from .permission import PermissionService
|
|
6
|
+
from .logger import LoggerService, LoggerChain
|
|
7
|
+
from .redis import RedisService
|
|
8
|
+
from .encryption import EncryptionService
|
|
9
|
+
from .cache import CacheService
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuthService",
|
|
13
|
+
"RoleService",
|
|
14
|
+
"PermissionService",
|
|
15
|
+
"LoggerService",
|
|
16
|
+
"LoggerChain",
|
|
17
|
+
"RedisService",
|
|
18
|
+
"EncryptionService",
|
|
19
|
+
"CacheService",
|
|
20
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication service for token validation and user management.
|
|
3
|
+
|
|
4
|
+
This module handles authentication operations including client token management,
|
|
5
|
+
token validation, user information retrieval, and logout functionality.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from ..models.config import UserInfo, AuthResult
|
|
10
|
+
from ..services.redis import RedisService
|
|
11
|
+
from ..utils.http_client import HttpClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthService:
|
|
15
|
+
"""Authentication service for token validation and user management."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, http_client: HttpClient, redis: RedisService):
|
|
18
|
+
"""
|
|
19
|
+
Initialize authentication service.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
http_client: HTTP client instance
|
|
23
|
+
redis: Redis service instance
|
|
24
|
+
"""
|
|
25
|
+
self.config = http_client.config
|
|
26
|
+
self.http_client = http_client
|
|
27
|
+
self.redis = redis
|
|
28
|
+
|
|
29
|
+
async def get_environment_token(self) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Get environment token using client credentials.
|
|
32
|
+
|
|
33
|
+
This is called automatically by HttpClient, but can be called manually if needed.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Client token string
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
AuthenticationError: If token fetch fails
|
|
40
|
+
"""
|
|
41
|
+
return await self.http_client.get_environment_token()
|
|
42
|
+
|
|
43
|
+
def login(self, redirect_uri: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Initiate login flow by redirecting to controller.
|
|
46
|
+
|
|
47
|
+
Returns the login URL for browser redirect or manual navigation.
|
|
48
|
+
Backend will extract environment and application from client token.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
redirect_uri: URI to redirect to after successful login
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Login URL string
|
|
55
|
+
"""
|
|
56
|
+
return f"{self.config.controller_url}/api/auth/login?redirect={redirect_uri}"
|
|
57
|
+
|
|
58
|
+
async def validate_token(self, token: str) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Validate token with controller.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
token: JWT token to validate
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if token is valid, False otherwise
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
result = await self.http_client.authenticated_request(
|
|
70
|
+
"POST",
|
|
71
|
+
"/api/auth/validate", # Backend knows app/env from client token
|
|
72
|
+
token
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
auth_result = AuthResult(**result)
|
|
76
|
+
return auth_result.authenticated
|
|
77
|
+
|
|
78
|
+
except Exception:
|
|
79
|
+
# Token validation failed, return false
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
async def get_user(self, token: str) -> Optional[UserInfo]:
|
|
83
|
+
"""
|
|
84
|
+
Get user information from token.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
token: JWT token
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
UserInfo if token is valid, None otherwise
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
result = await self.http_client.authenticated_request(
|
|
94
|
+
"POST",
|
|
95
|
+
"/api/auth/validate",
|
|
96
|
+
token
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
auth_result = AuthResult(**result)
|
|
100
|
+
|
|
101
|
+
if auth_result.authenticated and auth_result.user:
|
|
102
|
+
return auth_result.user
|
|
103
|
+
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
except Exception:
|
|
107
|
+
# Failed to get user info, return null
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
async def get_user_info(self, token: str) -> Optional[UserInfo]:
|
|
111
|
+
"""
|
|
112
|
+
Get user information from GET /api/auth/user endpoint.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
token: JWT token
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
UserInfo if token is valid, None otherwise
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
user_data = await self.http_client.authenticated_request(
|
|
122
|
+
"GET",
|
|
123
|
+
"/api/auth/user",
|
|
124
|
+
token
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return UserInfo(**user_data)
|
|
128
|
+
|
|
129
|
+
except Exception:
|
|
130
|
+
# Failed to get user info, return None
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
async def logout(self) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Logout user.
|
|
136
|
+
|
|
137
|
+
Backend extracts app/env from client token (no body needed).
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
MisoClientError: If logout fails
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
# Backend extracts app/env from client token
|
|
144
|
+
await self.http_client.request("POST", "/api/auth/logout")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
# Logout failed, re-raise error for application to handle
|
|
147
|
+
from ..errors import MisoClientError
|
|
148
|
+
raise MisoClientError(f"Logout failed: {str(e)}")
|
|
149
|
+
|
|
150
|
+
async def is_authenticated(self, token: str) -> bool:
|
|
151
|
+
"""
|
|
152
|
+
Check if user is authenticated (has valid token).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
token: JWT token
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if user is authenticated, False otherwise
|
|
159
|
+
"""
|
|
160
|
+
return await self.validate_token(token)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache service with Redis support and in-memory TTL fallback.
|
|
3
|
+
|
|
4
|
+
This module provides a generic caching service that can be used anywhere.
|
|
5
|
+
It supports Redis-backed caching when available, with automatic fallback to
|
|
6
|
+
in-memory TTL-based caching when Redis is unavailable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Optional, Dict, Tuple
|
|
12
|
+
from ..services.redis import RedisService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheService:
|
|
16
|
+
"""Cache service with Redis and in-memory TTL fallback."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, redis: Optional[RedisService] = None):
|
|
19
|
+
"""
|
|
20
|
+
Initialize cache service.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
redis: Optional RedisService instance. If provided, Redis will be used
|
|
24
|
+
as primary cache with in-memory as fallback. If None, only
|
|
25
|
+
in-memory caching is used.
|
|
26
|
+
"""
|
|
27
|
+
self.redis = redis
|
|
28
|
+
# In-memory cache: {key: (value, expiration_timestamp)}
|
|
29
|
+
self._memory_cache: Dict[str, Tuple[Any, float]] = {}
|
|
30
|
+
# Cleanup threshold: clean expired entries if cache exceeds this size
|
|
31
|
+
self._cleanup_threshold = 1000
|
|
32
|
+
|
|
33
|
+
def _is_expired(self, expiration: float) -> bool:
|
|
34
|
+
"""Check if entry has expired."""
|
|
35
|
+
return time.time() > expiration
|
|
36
|
+
|
|
37
|
+
def _cleanup_expired(self) -> None:
|
|
38
|
+
"""Remove expired entries from memory cache."""
|
|
39
|
+
if len(self._memory_cache) <= self._cleanup_threshold:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
expired_keys = [
|
|
43
|
+
key for key, (_, expiration) in self._memory_cache.items()
|
|
44
|
+
if self._is_expired(expiration)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
for key in expired_keys:
|
|
48
|
+
del self._memory_cache[key]
|
|
49
|
+
|
|
50
|
+
def _serialize_value(self, value: Any) -> str:
|
|
51
|
+
"""
|
|
52
|
+
Serialize value to JSON string.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
value: Value to serialize (can be any JSON-serializable type)
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
JSON string representation
|
|
59
|
+
"""
|
|
60
|
+
# For primitive types (str, int, float, bool, None), return as-is or simple string
|
|
61
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
62
|
+
if isinstance(value, str):
|
|
63
|
+
return value
|
|
64
|
+
return json.dumps(value)
|
|
65
|
+
|
|
66
|
+
# For complex types, use JSON serialization with a marker
|
|
67
|
+
return json.dumps({"__cached_value__": value})
|
|
68
|
+
|
|
69
|
+
def _deserialize_value(self, value_str: str) -> Any:
|
|
70
|
+
"""
|
|
71
|
+
Deserialize JSON string back to original value.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
value_str: JSON string to deserialize
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Deserialized value
|
|
78
|
+
"""
|
|
79
|
+
if not value_str:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Try to parse as JSON
|
|
84
|
+
parsed = json.loads(value_str)
|
|
85
|
+
# Check if it's our wrapped format
|
|
86
|
+
if isinstance(parsed, dict) and "__cached_value__" in parsed:
|
|
87
|
+
return parsed["__cached_value__"]
|
|
88
|
+
# Otherwise return as-is (could be a string or other JSON value)
|
|
89
|
+
return parsed
|
|
90
|
+
except (json.JSONDecodeError, TypeError):
|
|
91
|
+
# If JSON parsing fails, assume it's a plain string
|
|
92
|
+
return value_str
|
|
93
|
+
|
|
94
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
95
|
+
"""
|
|
96
|
+
Get cached value.
|
|
97
|
+
|
|
98
|
+
Checks Redis first (if available), then falls back to in-memory cache.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
key: Cache key
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Cached value if found, None otherwise
|
|
105
|
+
"""
|
|
106
|
+
# Try Redis first if available and connected
|
|
107
|
+
if self.redis and self.redis.is_connected():
|
|
108
|
+
try:
|
|
109
|
+
cached_value = await self.redis.get(key)
|
|
110
|
+
if cached_value is not None:
|
|
111
|
+
return self._deserialize_value(cached_value)
|
|
112
|
+
except Exception:
|
|
113
|
+
# Redis operation failed, fall through to memory cache
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# Fallback to in-memory cache
|
|
117
|
+
if key in self._memory_cache:
|
|
118
|
+
value, expiration = self._memory_cache[key]
|
|
119
|
+
if not self._is_expired(expiration):
|
|
120
|
+
return value
|
|
121
|
+
else:
|
|
122
|
+
# Entry expired, remove it
|
|
123
|
+
del self._memory_cache[key]
|
|
124
|
+
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
async def set(self, key: str, value: Any, ttl: int) -> bool:
|
|
128
|
+
"""
|
|
129
|
+
Set cached value with TTL.
|
|
130
|
+
|
|
131
|
+
Stores in both Redis (if available) and in-memory cache.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
key: Cache key
|
|
135
|
+
value: Value to cache (any JSON-serializable type)
|
|
136
|
+
ttl: Time to live in seconds
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if successful, False otherwise
|
|
140
|
+
"""
|
|
141
|
+
serialized_value = self._serialize_value(value)
|
|
142
|
+
success = False
|
|
143
|
+
|
|
144
|
+
# Store in Redis if available
|
|
145
|
+
if self.redis and self.redis.is_connected():
|
|
146
|
+
try:
|
|
147
|
+
success = await self.redis.set(key, serialized_value, ttl)
|
|
148
|
+
except Exception:
|
|
149
|
+
# Redis operation failed, continue to memory cache
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Also store in memory cache
|
|
153
|
+
expiration = time.time() + ttl
|
|
154
|
+
self._memory_cache[key] = (value, expiration)
|
|
155
|
+
|
|
156
|
+
# Cleanup expired entries periodically
|
|
157
|
+
self._cleanup_expired()
|
|
158
|
+
|
|
159
|
+
return success or True # Return True if at least memory cache succeeded
|
|
160
|
+
|
|
161
|
+
async def delete(self, key: str) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Delete cached value.
|
|
164
|
+
|
|
165
|
+
Deletes from both Redis (if available) and in-memory cache.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
key: Cache key
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if deleted from at least one cache, False otherwise
|
|
172
|
+
"""
|
|
173
|
+
deleted = False
|
|
174
|
+
|
|
175
|
+
# Delete from Redis if available
|
|
176
|
+
if self.redis and self.redis.is_connected():
|
|
177
|
+
try:
|
|
178
|
+
deleted = await self.redis.delete(key)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
# Delete from memory cache
|
|
183
|
+
if key in self._memory_cache:
|
|
184
|
+
del self._memory_cache[key]
|
|
185
|
+
deleted = True
|
|
186
|
+
|
|
187
|
+
return deleted
|
|
188
|
+
|
|
189
|
+
async def clear(self) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Clear all cached values.
|
|
192
|
+
|
|
193
|
+
Clears both Redis (if available) and in-memory cache.
|
|
194
|
+
Note: Redis clear operation only clears keys with the configured prefix,
|
|
195
|
+
not the entire Redis database.
|
|
196
|
+
"""
|
|
197
|
+
# Clear memory cache
|
|
198
|
+
self._memory_cache.clear()
|
|
199
|
+
|
|
200
|
+
# For Redis, we would need to delete all keys with the prefix
|
|
201
|
+
# This is more complex and potentially dangerous, so we'll skip it
|
|
202
|
+
# Users should use delete() for specific keys or clear Redis manually
|
|
203
|
+
# if needed
|
|
204
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Encryption service for sensitive data using Fernet symmetric encryption.
|
|
3
|
+
|
|
4
|
+
This module provides encryption/decryption functionality that can be used anywhere
|
|
5
|
+
in the application. It supports reading the encryption key from environment variables
|
|
6
|
+
or accepting it as a constructor parameter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import base64
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from cryptography.fernet import Fernet
|
|
13
|
+
from ..errors import ConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EncryptionService:
|
|
17
|
+
"""Service for encrypting/decrypting sensitive data using Fernet encryption."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, encryption_key: Optional[str] = None):
|
|
20
|
+
"""
|
|
21
|
+
Initialize encryption service with key from environment or parameter.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
encryption_key: Optional encryption key. If not provided, reads from EXTENSION_KEY env var.
|
|
25
|
+
If provided, overrides environment variable.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ConfigurationError: If encryption key is not found or invalid
|
|
29
|
+
"""
|
|
30
|
+
# Use provided key, or fall back to environment variable
|
|
31
|
+
key = encryption_key or os.environ.get("ENCRYPTION_KEY")
|
|
32
|
+
|
|
33
|
+
if not key:
|
|
34
|
+
raise ConfigurationError(
|
|
35
|
+
"ENCRYPTION_KEY not found. Either set ENCRYPTION_KEY environment variable "
|
|
36
|
+
"or pass encryption_key parameter to EncryptionService constructor."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
# Fernet.generate_key() returns bytes, but env vars are strings
|
|
41
|
+
# Convert string to bytes if needed
|
|
42
|
+
key_bytes = key.encode() if isinstance(key, str) else key
|
|
43
|
+
self.fernet = Fernet(key_bytes)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise ConfigurationError(
|
|
46
|
+
f"Failed to initialize encryption service with provided key: {str(e)}"
|
|
47
|
+
) from e
|
|
48
|
+
|
|
49
|
+
def encrypt(self, plaintext: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Encrypt sensitive data.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
plaintext: Plain text string to encrypt
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Base64-encoded encrypted string
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If encryption fails
|
|
61
|
+
"""
|
|
62
|
+
if not plaintext:
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
encrypted = self.fernet.encrypt(plaintext.encode())
|
|
67
|
+
return base64.b64encode(encrypted).decode()
|
|
68
|
+
except Exception as e:
|
|
69
|
+
raise ValueError(f"Failed to encrypt data: {str(e)}") from e
|
|
70
|
+
|
|
71
|
+
def decrypt(self, encrypted_text: str) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Decrypt sensitive data.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
encrypted_text: Base64-encoded encrypted string
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Decrypted plain text string
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: If decryption fails or data is invalid
|
|
83
|
+
"""
|
|
84
|
+
if not encrypted_text:
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
decoded = base64.b64decode(encrypted_text.encode())
|
|
89
|
+
decrypted = self.fernet.decrypt(decoded)
|
|
90
|
+
return decrypted.decode()
|
|
91
|
+
except Exception as e:
|
|
92
|
+
raise ValueError(f"Failed to decrypt data: {str(e)}") from e
|
|
93
|
+
|