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,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
+