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,179 @@
1
+ """
2
+ Redis service for caching and log queuing.
3
+
4
+ This module provides Redis connectivity with graceful degradation when Redis
5
+ is unavailable. It handles caching of roles and permissions, and log queuing.
6
+ """
7
+
8
+ import redis.asyncio as redis
9
+ from typing import Optional
10
+ from ..models.config import RedisConfig
11
+
12
+
13
+ class RedisService:
14
+ """Redis service for caching and log queuing."""
15
+
16
+ def __init__(self, config: Optional[RedisConfig] = None):
17
+ """
18
+ Initialize Redis service.
19
+
20
+ Args:
21
+ config: Optional Redis configuration
22
+ """
23
+ self.config = config
24
+ self.redis: Optional[redis.Redis] = None
25
+ self.connected = False
26
+
27
+ async def connect(self) -> None:
28
+ """
29
+ Connect to Redis.
30
+
31
+ Raises:
32
+ Exception: If connection fails and config is provided
33
+ """
34
+ if not self.config:
35
+ print("Redis not configured, using controller fallback")
36
+ return
37
+
38
+ try:
39
+ self.redis = redis.Redis(
40
+ host=self.config.host,
41
+ port=self.config.port,
42
+ password=self.config.password,
43
+ db=self.config.db,
44
+ decode_responses=True,
45
+ retry_on_timeout=True,
46
+ socket_connect_timeout=5,
47
+ socket_timeout=5,
48
+ )
49
+
50
+ # Test connection
51
+ # Some redis stubs type ping as possibly non-awaitable; support both
52
+ resp = self.redis.ping()
53
+ if hasattr(resp, "__await__"):
54
+ await resp # type: ignore[misc]
55
+ self.connected = True
56
+ print("Connected to Redis")
57
+
58
+ except Exception as error:
59
+ print(f"Failed to connect to Redis: {error}")
60
+ self.connected = False
61
+ if self.config: # Only raise if Redis was configured
62
+ raise error
63
+
64
+ async def disconnect(self) -> None:
65
+ """Disconnect from Redis."""
66
+ if self.redis:
67
+ await self.redis.aclose()
68
+ self.connected = False
69
+ print("Disconnected from Redis")
70
+
71
+ def is_connected(self) -> bool:
72
+ """
73
+ Check if Redis is connected.
74
+
75
+ Returns:
76
+ True if connected, False otherwise
77
+ """
78
+ return self.connected and self.redis is not None
79
+
80
+ async def get(self, key: str) -> Optional[str]:
81
+ """
82
+ Get value from Redis.
83
+
84
+ Args:
85
+ key: Redis key
86
+
87
+ Returns:
88
+ Value if found, None otherwise
89
+ """
90
+ if not self.is_connected():
91
+ return None
92
+
93
+ try:
94
+ assert self.redis is not None
95
+ prefixed_key = f"{self.config.key_prefix}{key}" if self.config else key
96
+ resp = self.redis.get(prefixed_key)
97
+ if hasattr(resp, "__await__"):
98
+ result = await resp # type: ignore[misc]
99
+ else:
100
+ result = resp
101
+ return None if result is None else str(result)
102
+ except Exception as error:
103
+ print(f"Redis get error: {error}")
104
+ return None
105
+
106
+ async def set(self, key: str, value: str, ttl: int) -> bool:
107
+ """
108
+ Set value in Redis with TTL.
109
+
110
+ Args:
111
+ key: Redis key
112
+ value: Value to store
113
+ ttl: Time to live in seconds
114
+
115
+ Returns:
116
+ True if successful, False otherwise
117
+ """
118
+ if not self.is_connected():
119
+ return False
120
+
121
+ try:
122
+ assert self.redis is not None
123
+ prefixed_key = f"{self.config.key_prefix}{key}" if self.config else key
124
+ resp = self.redis.setex(prefixed_key, ttl, value)
125
+ if hasattr(resp, "__await__"):
126
+ await resp # type: ignore[misc]
127
+ return True
128
+ except Exception as error:
129
+ print(f"Redis set error: {error}")
130
+ return False
131
+
132
+ async def delete(self, key: str) -> bool:
133
+ """
134
+ Delete key from Redis.
135
+
136
+ Args:
137
+ key: Redis key
138
+
139
+ Returns:
140
+ True if successful, False otherwise
141
+ """
142
+ if not self.is_connected():
143
+ return False
144
+
145
+ try:
146
+ assert self.redis is not None
147
+ prefixed_key = f"{self.config.key_prefix}{key}" if self.config else key
148
+ resp = self.redis.delete(prefixed_key)
149
+ if hasattr(resp, "__await__"):
150
+ await resp # type: ignore[misc]
151
+ return True
152
+ except Exception as error:
153
+ print(f"Redis delete error: {error}")
154
+ return False
155
+
156
+ async def rpush(self, queue: str, value: str) -> bool:
157
+ """
158
+ Push value to Redis list (for log queuing).
159
+
160
+ Args:
161
+ queue: Queue name
162
+ value: Value to push
163
+
164
+ Returns:
165
+ True if successful, False otherwise
166
+ """
167
+ if not self.is_connected():
168
+ return False
169
+
170
+ try:
171
+ assert self.redis is not None
172
+ prefixed_queue = f"{self.config.key_prefix}{queue}" if self.config else queue
173
+ resp = self.redis.rpush(prefixed_queue, value)
174
+ if hasattr(resp, "__await__"):
175
+ await resp # type: ignore[misc]
176
+ return True
177
+ except Exception as error:
178
+ print(f"Redis rpush error: {error}")
179
+ return False
@@ -0,0 +1,180 @@
1
+ """
2
+ Role service for user authorization with caching.
3
+
4
+ This module handles role-based access control with caching support.
5
+ Roles 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 RoleResult
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 RoleService:
18
+ """Role service for user authorization with caching."""
19
+
20
+ def __init__(self, http_client: HttpClient, cache: CacheService):
21
+ """
22
+ Initialize role 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.role_ttl = self.config.role_ttl
32
+
33
+ async def get_roles(self, token: str) -> List[str]:
34
+ """
35
+ Get user roles 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 roles
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"roles:{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("roles", []))
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"roles:{user_id}"
68
+
69
+ # Cache miss - fetch from controller
70
+ role_result = await self.http_client.authenticated_request(
71
+ "GET",
72
+ "/api/auth/roles", # Backend knows app/env from client token
73
+ token
74
+ )
75
+
76
+ role_data = RoleResult(**role_result)
77
+ roles = role_data.roles 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
+ {"roles": roles, "timestamp": int(time.time() * 1000)},
84
+ self.role_ttl
85
+ )
86
+
87
+ return roles
88
+
89
+ except Exception:
90
+ # Failed to get roles, return empty list
91
+ return []
92
+
93
+ async def has_role(self, token: str, role: str) -> bool:
94
+ """
95
+ Check if user has specific role.
96
+
97
+ Args:
98
+ token: JWT token
99
+ role: Role to check
100
+
101
+ Returns:
102
+ True if user has the role, False otherwise
103
+ """
104
+ roles = await self.get_roles(token)
105
+ return role in roles
106
+
107
+ async def has_any_role(self, token: str, roles: List[str]) -> bool:
108
+ """
109
+ Check if user has any of the specified roles.
110
+
111
+ Args:
112
+ token: JWT token
113
+ roles: List of roles to check
114
+
115
+ Returns:
116
+ True if user has any of the roles, False otherwise
117
+ """
118
+ user_roles = await self.get_roles(token)
119
+ return any(role in user_roles for role in roles)
120
+
121
+ async def has_all_roles(self, token: str, roles: List[str]) -> bool:
122
+ """
123
+ Check if user has all of the specified roles.
124
+
125
+ Args:
126
+ token: JWT token
127
+ roles: List of roles to check
128
+
129
+ Returns:
130
+ True if user has all roles, False otherwise
131
+ """
132
+ user_roles = await self.get_roles(token)
133
+ return all(role in user_roles for role in roles)
134
+
135
+ async def refresh_roles(self, token: str) -> List[str]:
136
+ """
137
+ Force refresh roles from controller (bypass cache).
138
+
139
+ Args:
140
+ token: JWT token
141
+
142
+ Returns:
143
+ Fresh list of user roles
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"roles:{user_id}"
158
+
159
+ # Fetch fresh roles from controller using refresh endpoint
160
+ role_result = await self.http_client.authenticated_request(
161
+ "GET",
162
+ "/api/auth/roles/refresh",
163
+ token
164
+ )
165
+
166
+ role_data = RoleResult(**role_result)
167
+ roles = role_data.roles or []
168
+
169
+ # Update cache with fresh data (CacheService handles Redis + in-memory automatically)
170
+ await self.cache.set(
171
+ cache_key,
172
+ {"roles": roles, "timestamp": int(time.time() * 1000)},
173
+ self.role_ttl
174
+ )
175
+
176
+ return roles
177
+
178
+ except Exception:
179
+ # Failed to refresh roles, return empty list
180
+ return []
@@ -0,0 +1,15 @@
1
+ """Utility modules for MisoClient SDK."""
2
+
3
+ from .http_client import HttpClient
4
+ from .config_loader import load_config
5
+ from .data_masker import DataMasker
6
+ from .jwt_tools import decode_token, extract_user_id, extract_session_id
7
+
8
+ __all__ = [
9
+ "HttpClient",
10
+ "load_config",
11
+ "DataMasker",
12
+ "decode_token",
13
+ "extract_user_id",
14
+ "extract_session_id",
15
+ ]
@@ -0,0 +1,87 @@
1
+ """
2
+ Configuration loader utility.
3
+
4
+ Automatically loads environment variables with sensible defaults.
5
+ """
6
+
7
+ import os
8
+ from typing import Literal, cast
9
+ from ..models.config import MisoClientConfig, RedisConfig
10
+ from ..errors import ConfigurationError
11
+
12
+
13
+ def load_config() -> MisoClientConfig:
14
+ """
15
+ Load configuration from environment variables with defaults.
16
+
17
+ Required environment variables:
18
+ - MISO_CONTROLLER_URL (or default to https://controller.aifabrix.ai)
19
+ - MISO_CLIENTID or MISO_CLIENT_ID
20
+ - MISO_CLIENTSECRET or MISO_CLIENT_SECRET
21
+
22
+ Optional environment variables:
23
+ - MISO_LOG_LEVEL (debug, info, warn, error)
24
+ - REDIS_HOST (if Redis is used)
25
+ - REDIS_PORT (default: 6379)
26
+ - REDIS_PASSWORD
27
+ - REDIS_DB (default: 0)
28
+ - REDIS_KEY_PREFIX (default: miso:)
29
+
30
+ Returns:
31
+ MisoClientConfig instance
32
+
33
+ Raises:
34
+ ConfigurationError: If required environment variables are missing
35
+ """
36
+ # Load dotenv if available (similar to TypeScript dotenv/config)
37
+ try:
38
+ from dotenv import load_dotenv
39
+ load_dotenv()
40
+ except ImportError:
41
+ pass # dotenv not installed, continue without it
42
+
43
+ controller_url = os.environ.get("MISO_CONTROLLER_URL") or "https://controller.aifabrix.ai"
44
+
45
+ client_id = os.environ.get("MISO_CLIENTID") or os.environ.get("MISO_CLIENT_ID") or ""
46
+ if not client_id:
47
+ raise ConfigurationError("MISO_CLIENTID environment variable is required")
48
+
49
+ client_secret = os.environ.get("MISO_CLIENTSECRET") or os.environ.get("MISO_CLIENT_SECRET") or ""
50
+ if not client_secret:
51
+ raise ConfigurationError("MISO_CLIENTSECRET environment variable is required")
52
+
53
+ log_level_str = os.environ.get("MISO_LOG_LEVEL", "info")
54
+ if log_level_str not in ["debug", "info", "warn", "error"]:
55
+ log_level_str = "info"
56
+ # Constrain to Literal for type-checker
57
+ log_level: Literal["debug", "info", "warn", "error"] = cast(
58
+ Literal["debug", "info", "warn", "error"], log_level_str
59
+ )
60
+
61
+ config: MisoClientConfig = MisoClientConfig(
62
+ controller_url=controller_url,
63
+ client_id=client_id,
64
+ client_secret=client_secret,
65
+ log_level=log_level,
66
+ )
67
+
68
+ # Optional Redis configuration
69
+ redis_host = os.environ.get("REDIS_HOST")
70
+ if redis_host:
71
+ redis_port = int(os.environ.get("REDIS_PORT", "6379"))
72
+ redis_password = os.environ.get("REDIS_PASSWORD")
73
+ redis_db = int(os.environ.get("REDIS_DB", "0")) if os.environ.get("REDIS_DB") else 0
74
+ redis_key_prefix = os.environ.get("REDIS_KEY_PREFIX", "miso:")
75
+
76
+ redis_config = RedisConfig(
77
+ host=redis_host,
78
+ port=redis_port,
79
+ password=redis_password,
80
+ db=redis_db,
81
+ key_prefix=redis_key_prefix,
82
+ )
83
+
84
+ config.redis = redis_config
85
+
86
+ return config
87
+
@@ -0,0 +1,156 @@
1
+ """
2
+ Data masker utility for client-side sensitive data protection.
3
+
4
+ Implements ISO 27001 data protection controls by masking sensitive fields
5
+ in log entries and context data.
6
+ """
7
+
8
+ from typing import Any, Set
9
+
10
+
11
+ class DataMasker:
12
+ """Static class for masking sensitive data."""
13
+
14
+ MASKED_VALUE = "***MASKED***"
15
+
16
+ # Set of sensitive field names (normalized)
17
+ _sensitive_fields: Set[str] = {
18
+ "password",
19
+ "passwd",
20
+ "pwd",
21
+ "secret",
22
+ "token",
23
+ "key",
24
+ "auth",
25
+ "authorization",
26
+ "cookie",
27
+ "session",
28
+ "ssn",
29
+ "creditcard",
30
+ "cc",
31
+ "cvv",
32
+ "pin",
33
+ "otp",
34
+ "apikey",
35
+ "accesstoken",
36
+ "refreshtoken",
37
+ "privatekey",
38
+ "secretkey",
39
+ }
40
+
41
+ @classmethod
42
+ def is_sensitive_field(cls, key: str) -> bool:
43
+ """
44
+ Check if a field name indicates sensitive data.
45
+
46
+ Args:
47
+ key: Field name to check
48
+
49
+ Returns:
50
+ True if field is sensitive, False otherwise
51
+ """
52
+ # Normalize key: lowercase and remove underscores/hyphens
53
+ normalized_key = key.lower().replace("_", "").replace("-", "")
54
+
55
+ # Check exact match
56
+ if normalized_key in cls._sensitive_fields:
57
+ return True
58
+
59
+ # Check if field contains sensitive keywords
60
+ for sensitive_field in cls._sensitive_fields:
61
+ if sensitive_field in normalized_key:
62
+ return True
63
+
64
+ return False
65
+
66
+ @classmethod
67
+ def mask_sensitive_data(cls, data: Any) -> Any:
68
+ """
69
+ Mask sensitive data in objects, arrays, or primitives.
70
+
71
+ Returns a masked copy without modifying the original.
72
+ Recursively processes nested objects and arrays.
73
+
74
+ Args:
75
+ data: Data to mask (dict, list, or primitive)
76
+
77
+ Returns:
78
+ Masked copy of the data
79
+ """
80
+ # Handle null and undefined
81
+ if data is None:
82
+ return data
83
+
84
+ # Handle primitives (string, number, boolean)
85
+ if not isinstance(data, (dict, list)):
86
+ return data
87
+
88
+ # Handle arrays
89
+ if isinstance(data, list):
90
+ return [cls.mask_sensitive_data(item) for item in data]
91
+
92
+ # Handle objects/dicts
93
+ masked: dict[str, Any] = {}
94
+ for key, value in data.items():
95
+ if cls.is_sensitive_field(key):
96
+ # Mask sensitive field
97
+ masked[key] = cls.MASKED_VALUE
98
+ elif isinstance(value, (dict, list)):
99
+ # Recursively mask nested objects
100
+ masked[key] = cls.mask_sensitive_data(value)
101
+ else:
102
+ # Keep non-sensitive value as-is
103
+ masked[key] = value
104
+
105
+ return masked
106
+
107
+ @classmethod
108
+ def mask_value(cls, value: str, show_first: int = 0, show_last: int = 0) -> str:
109
+ """
110
+ Mask specific value (useful for masking individual strings).
111
+
112
+ Args:
113
+ value: String value to mask
114
+ show_first: Number of characters to show at the start
115
+ show_last: Number of characters to show at the end
116
+
117
+ Returns:
118
+ Masked string value
119
+ """
120
+ if not value or len(value) <= show_first + show_last:
121
+ return cls.MASKED_VALUE
122
+
123
+ first = value[:show_first] if show_first > 0 else ""
124
+ last = value[-show_last:] if show_last > 0 else ""
125
+ masked_length = max(8, len(value) - show_first - show_last)
126
+ masked = "*" * masked_length
127
+
128
+ return f"{first}{masked}{last}"
129
+
130
+ @classmethod
131
+ def contains_sensitive_data(cls, data: Any) -> bool:
132
+ """
133
+ Check if data contains sensitive information.
134
+
135
+ Args:
136
+ data: Data to check
137
+
138
+ Returns:
139
+ True if data contains sensitive fields, False otherwise
140
+ """
141
+ if data is None or not isinstance(data, (dict, list)):
142
+ return False
143
+
144
+ if isinstance(data, list):
145
+ return any(cls.contains_sensitive_data(item) for item in data)
146
+
147
+ # Check object keys
148
+ for key, value in data.items():
149
+ if cls.is_sensitive_field(key):
150
+ return True
151
+ if isinstance(value, (dict, list)):
152
+ if cls.contains_sensitive_data(value):
153
+ return True
154
+
155
+ return False
156
+