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