dataman-engine 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.
File without changes
dataman/core/apps.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DatamanCoreConfig(AppConfig):
5
+ name = "dataman.core"
6
+ label = "dataman"
7
+
8
+ def ready(self):
9
+ import dataman.core.signals # noqa: F401
dataman/core/audit.py ADDED
@@ -0,0 +1,171 @@
1
+ import contextlib
2
+ import json
3
+ import logging
4
+ from typing import Any
5
+
6
+ audit_logger = logging.getLogger("dataman.audit")
7
+
8
+ SENSITIVE_EXACT_KEYS = {
9
+ "password",
10
+ "passwd",
11
+ "secret",
12
+ "token",
13
+ "auth",
14
+ "authorization",
15
+ "credit_card",
16
+ "api_key",
17
+ "access_token",
18
+ "refresh_token",
19
+ "hash",
20
+ "hashed_secret",
21
+ "private_key",
22
+ "secret_key",
23
+ "cvv",
24
+ "cvv2",
25
+ "pin",
26
+ "ssn",
27
+ "social_security",
28
+ "card_number",
29
+ "passcode",
30
+ }
31
+
32
+ SAFE_KEY_SUFFIXES = (
33
+ "_id",
34
+ "_name",
35
+ "_prefix",
36
+ "_type",
37
+ "_count",
38
+ "_scopes",
39
+ "_status",
40
+ "_at",
41
+ )
42
+
43
+
44
+ def is_sensitive_key(key: str) -> bool:
45
+ """Checks if a field key corresponds to a secret credential or sensitive PII."""
46
+ k_lower = key.lower()
47
+ if any(k_lower.endswith(suffix) for suffix in SAFE_KEY_SUFFIXES):
48
+ return False
49
+ if k_lower in SENSITIVE_EXACT_KEYS:
50
+ return True
51
+ if any(
52
+ s_key in k_lower
53
+ for s_key in ("password", "secret", "credit_card", "api_key", "authorization")
54
+ ):
55
+ return True
56
+ return k_lower.endswith(("_token", "_secret", "_key"))
57
+
58
+
59
+ def sanitize_payload(data: Any) -> Any:
60
+ """Recursively masks sensitive keys in payloads and dictionaries."""
61
+ if isinstance(data, dict):
62
+ sanitized = {}
63
+ for k, v in data.items():
64
+ if is_sensitive_key(k):
65
+ sanitized[k] = "********"
66
+ else:
67
+ sanitized[k] = sanitize_payload(v)
68
+ return sanitized
69
+ elif isinstance(data, list):
70
+ return [sanitize_payload(item) for item in data]
71
+ return data
72
+
73
+
74
+ def get_client_ip(request: Any) -> str | None:
75
+ """Resolves client IP address taking proxies and load balancers into account."""
76
+ if not request:
77
+ return None
78
+
79
+ # Check Cloudflare
80
+ cf_ip = request.META.get("HTTP_CF_CONNECTING_IP")
81
+ if cf_ip:
82
+ return cf_ip.strip()
83
+
84
+ # Check X-Forwarded-For
85
+ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
86
+ if x_forwarded_for:
87
+ return x_forwarded_for.split(",")[0].strip()
88
+
89
+ # Check X-Real-IP
90
+ x_real_ip = request.META.get("HTTP_X_REAL_IP")
91
+ if x_real_ip:
92
+ return x_real_ip.strip()
93
+
94
+ return request.META.get("REMOTE_ADDR")
95
+
96
+
97
+ def get_user_agent(request: Any) -> str:
98
+ """Extracts User-Agent string from request headers."""
99
+ if not request:
100
+ return ""
101
+ return request.META.get("HTTP_USER_AGENT", "")[:500]
102
+
103
+
104
+ def log_audit_event(
105
+ event_type: str,
106
+ actor: str | None = None,
107
+ ip_address: str | None = None,
108
+ user_agent: str | None = None,
109
+ details: dict | None = None,
110
+ severity: str = "INFO",
111
+ status_code: int | None = None,
112
+ request: Any = None,
113
+ ) -> Any | None:
114
+ """
115
+ Enterprise Audit Logging function:
116
+ 1. Extracts actor, IP, and User-Agent from request if available.
117
+ 2. Redacts sensitive PII keys.
118
+ 3. Persists record in database AuditLog model.
119
+ 4. Emits structured JSON log via logging.getLogger('dataman.audit').
120
+ """
121
+ if request:
122
+ if not ip_address:
123
+ ip_address = get_client_ip(request)
124
+ if not user_agent:
125
+ user_agent = get_user_agent(request)
126
+ if not actor:
127
+ if (
128
+ hasattr(request, "auth")
129
+ and request.auth
130
+ and hasattr(request.auth, "prefix")
131
+ ):
132
+ actor = f"Token:{request.auth.prefix}"
133
+ else:
134
+ user = getattr(request, "user", None)
135
+ if user and getattr(user, "is_authenticated", False):
136
+ actor = str(user)
137
+
138
+ actor = actor or "System"
139
+ sanitized_details = sanitize_payload(details or {})
140
+
141
+ # 1. Structured log stream
142
+ log_payload = {
143
+ "event_type": event_type,
144
+ "actor": actor,
145
+ "ip_address": ip_address,
146
+ "user_agent": user_agent,
147
+ "status_code": status_code,
148
+ "severity": severity,
149
+ "details": sanitized_details,
150
+ }
151
+
152
+ log_level = getattr(logging, severity.upper(), logging.INFO)
153
+ with contextlib.suppress(Exception):
154
+ audit_logger.log(log_level, json.dumps(log_payload, default=str))
155
+
156
+ # 2. Database model persistence
157
+ try:
158
+ from dataman.core.models import AuditLog
159
+
160
+ return AuditLog.objects.create(
161
+ event_type=event_type,
162
+ actor=actor,
163
+ ip_address=ip_address,
164
+ user_agent=user_agent or "",
165
+ status_code=status_code,
166
+ severity=severity.upper(),
167
+ details=sanitized_details,
168
+ )
169
+ except Exception as e:
170
+ audit_logger.error(f"Failed to persist AuditLog to DB: {e}")
171
+ return None
dataman/core/auth.py ADDED
@@ -0,0 +1,106 @@
1
+ import hashlib
2
+
3
+ from django.utils import timezone
4
+ from rest_framework import authentication, exceptions
5
+
6
+ from dataman.core.audit import log_audit_event
7
+ from dataman.core.models import APIToken
8
+
9
+
10
+ class ServiceUser:
11
+ """A dummy user class to represent a Service Token."""
12
+
13
+ is_authenticated = True
14
+ is_active = True
15
+ is_staff = False
16
+ is_superuser = False
17
+
18
+ def __str__(self):
19
+ return "ServiceTokenUser"
20
+
21
+
22
+ class ServiceTokenAuthentication(authentication.BaseAuthentication):
23
+ """Authenticates requests using the dataman APIToken model."""
24
+
25
+ def authenticate(self, request):
26
+ auth = request.headers.get("Authorization", "").split()
27
+
28
+ if not auth or auth[0].lower() != "token":
29
+ return None
30
+
31
+ if len(auth) == 1 or len(auth) > 2:
32
+ raise exceptions.AuthenticationFailed(
33
+ "Invalid token header. No credentials provided."
34
+ )
35
+
36
+ prefix = ""
37
+ try:
38
+ prefix, secret = auth[1].split("_", 1)
39
+ token = APIToken.objects.get(prefix=prefix)
40
+
41
+ hashed_input = hashlib.sha256(secret.encode()).hexdigest()
42
+ if hashed_input != token.hashed_secret:
43
+ log_audit_event(
44
+ event_type="TOKEN_AUTH_FAILED",
45
+ actor=f"Token:{prefix}",
46
+ request=request,
47
+ details={"prefix": prefix, "reason": "Secret hash mismatch"},
48
+ severity="WARNING",
49
+ status_code=401,
50
+ )
51
+ raise exceptions.AuthenticationFailed("Invalid token.")
52
+
53
+ # Check if token is active
54
+ if not getattr(token, "is_active", True):
55
+ log_audit_event(
56
+ event_type="TOKEN_AUTH_FAILED",
57
+ actor=f"Token:{prefix}",
58
+ request=request,
59
+ details={
60
+ "prefix": prefix,
61
+ "token_name": token.name,
62
+ "reason": "Token is disabled",
63
+ },
64
+ severity="WARNING",
65
+ status_code=401,
66
+ )
67
+ raise exceptions.AuthenticationFailed("Token is inactive.")
68
+
69
+ # Check if token has expired
70
+ if token.expires_at and timezone.now() > token.expires_at:
71
+ log_audit_event(
72
+ event_type="TOKEN_EXPIRED",
73
+ actor=f"Token:{prefix}",
74
+ request=request,
75
+ details={
76
+ "prefix": prefix,
77
+ "token_name": token.name,
78
+ "expired_at": token.expires_at.isoformat(),
79
+ },
80
+ severity="WARNING",
81
+ status_code=401,
82
+ )
83
+ raise exceptions.AuthenticationFailed("Token has expired.")
84
+
85
+ except (ValueError, APIToken.DoesNotExist) as e:
86
+ log_audit_event(
87
+ event_type="TOKEN_AUTH_FAILED",
88
+ actor=f"Token:{prefix}" if prefix else "Unknown",
89
+ request=request,
90
+ details={
91
+ "prefix": prefix,
92
+ "reason": "Token prefix not found or invalid format",
93
+ },
94
+ severity="WARNING",
95
+ status_code=401,
96
+ )
97
+ raise exceptions.AuthenticationFailed("Invalid token.") from e
98
+
99
+ return (ServiceUser(), token)
100
+
101
+
102
+ class CsrfExemptSessionAuthentication(authentication.SessionAuthentication):
103
+ """SessionAuthentication that does not enforce CSRF checks for internal dashboard APIs."""
104
+
105
+ def enforce_csrf(self, request):
106
+ return # Skip CSRF check for authenticated dashboard session
dataman/core/crypto.py ADDED
@@ -0,0 +1,173 @@
1
+ import abc
2
+ import base64
3
+ import contextlib
4
+ import os
5
+ from typing import Any
6
+
7
+ from cryptography.hazmat.primitives import hashes
8
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
9
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
10
+
11
+
12
+ class CryptoError(Exception):
13
+ """Base exception for all DataMan cryptographic operations."""
14
+
15
+ pass
16
+
17
+
18
+ class DecryptionError(CryptoError):
19
+ """Raised when ciphertext decryption fails due to bad key, corruption, or tampering."""
20
+
21
+ pass
22
+
23
+
24
+ class BaseCryptoProvider(abc.ABC):
25
+ """Abstract base class for all DataMan encryption providers."""
26
+
27
+ @abc.abstractmethod
28
+ def encrypt(self, plaintext: Any, context: dict | None = None) -> str:
29
+ """Encrypts plaintext string/bytes and returns a formatted ciphertext string."""
30
+ pass
31
+
32
+ @abc.abstractmethod
33
+ def decrypt(self, ciphertext: str, context: dict | None = None) -> str:
34
+ """Decrypts a formatted ciphertext string and returns plaintext string."""
35
+ pass
36
+
37
+
38
+ class AESGCMCryptoProvider(BaseCryptoProvider):
39
+ """
40
+ Production AES-256-GCM authenticated encryption provider.
41
+
42
+ Generates 96-bit random nonces for every encryption call and produces
43
+ versioned, URL-safe base64 encoded ciphertexts prefixed with 'v1:'.
44
+ """
45
+
46
+ CIPHER_PREFIX = "v1:"
47
+ NONCE_BYTES = 12 # 96-bit standard nonce for AES-GCM
48
+ KEY_LENGTH_BYTES = 32 # 256-bit AES key
49
+
50
+ def __init__(self, key: bytes | str | None = None):
51
+ self._key = self._resolve_key(key)
52
+ self._aesgcm = AESGCM(self._key)
53
+
54
+ def _resolve_key(self, key: bytes | str | None = None) -> bytes:
55
+ if isinstance(key, bytes) and len(key) == self.KEY_LENGTH_BYTES:
56
+ return key
57
+
58
+ # Raw secret material
59
+ secret_source: bytes = b""
60
+ if isinstance(key, str) and key:
61
+ secret_source = key.encode("utf-8")
62
+ elif isinstance(key, bytes) and key:
63
+ secret_source = key
64
+ else:
65
+ # Check environment variables or Django settings
66
+ env_key = os.getenv("DATAMAN_ENCRYPTION_KEY")
67
+ if env_key:
68
+ secret_source = env_key.encode("utf-8")
69
+ else:
70
+ secret_key = os.getenv(
71
+ "DATAMAN_SECRET_KEY", "default-insecure-key-change-me"
72
+ )
73
+ with contextlib.suppress(Exception):
74
+ from django.conf import settings
75
+
76
+ if settings.configured and getattr(settings, "SECRET_KEY", None):
77
+ secret_key = settings.SECRET_KEY
78
+ secret_source = secret_key.encode("utf-8")
79
+
80
+ # Derive a cryptographically strong 256-bit key using HKDF-SHA256
81
+ hkdf = HKDF(
82
+ algorithm=hashes.SHA256(),
83
+ length=self.KEY_LENGTH_BYTES,
84
+ salt=b"dataman-field-level-encryption-salt",
85
+ info=b"dataman-aes256-gcm-key-derivation",
86
+ )
87
+ return hkdf.derive(secret_source)
88
+
89
+ def encrypt(self, plaintext: Any, context: dict | None = None) -> str:
90
+ if plaintext is None:
91
+ return None
92
+
93
+ if isinstance(plaintext, bytes):
94
+ data_bytes = plaintext
95
+ elif isinstance(plaintext, str):
96
+ data_bytes = plaintext.encode("utf-8")
97
+ else:
98
+ data_bytes = str(plaintext).encode("utf-8")
99
+
100
+ nonce = os.urandom(self.NONCE_BYTES)
101
+ associated_data = None
102
+ if context:
103
+ # Sort keys for deterministic bytes representation
104
+ associated_data = str(sorted(context.items())).encode("utf-8")
105
+
106
+ encrypted = self._aesgcm.encrypt(nonce, data_bytes, associated_data)
107
+ b64_payload = base64.urlsafe_b64encode(nonce + encrypted).decode("ascii")
108
+ return f"{self.CIPHER_PREFIX}{b64_payload}"
109
+
110
+ def decrypt(self, ciphertext: str, context: dict | None = None) -> str:
111
+ if ciphertext is None:
112
+ return None
113
+
114
+ if not isinstance(ciphertext, str):
115
+ return str(ciphertext)
116
+
117
+ # If not starting with our prefix, return as-is (graceful unencrypted handling)
118
+ if not ciphertext.startswith(self.CIPHER_PREFIX):
119
+ return ciphertext
120
+
121
+ b64_payload = ciphertext[len(self.CIPHER_PREFIX) :]
122
+ try:
123
+ raw = base64.urlsafe_b64decode(b64_payload.encode("ascii"))
124
+ if len(raw) < self.NONCE_BYTES + 16: # 12-byte nonce + 16-byte GCM tag
125
+ raise DecryptionError("Ciphertext payload is truncated or malformed.")
126
+
127
+ nonce = raw[: self.NONCE_BYTES]
128
+ encrypted_payload = raw[self.NONCE_BYTES :]
129
+
130
+ associated_data = None
131
+ if context:
132
+ associated_data = str(sorted(context.items())).encode("utf-8")
133
+
134
+ decrypted_bytes = self._aesgcm.decrypt(
135
+ nonce, encrypted_payload, associated_data
136
+ )
137
+ return decrypted_bytes.decode("utf-8")
138
+ except DecryptionError:
139
+ raise
140
+ except Exception as e:
141
+ raise DecryptionError(
142
+ f"Decryption failed: data was tampered with or invalid key used ({e})"
143
+ ) from e
144
+
145
+
146
+ _default_provider: BaseCryptoProvider | None = None
147
+
148
+
149
+ def get_crypto_provider() -> BaseCryptoProvider:
150
+ """Returns the globally configured Crypto Provider instance."""
151
+ global _default_provider
152
+ if _default_provider is not None:
153
+ return _default_provider
154
+
155
+ with contextlib.suppress(Exception):
156
+ import config
157
+
158
+ if hasattr(config, "CRYPTO_PROVIDER") and config.CRYPTO_PROVIDER:
159
+ if isinstance(config.CRYPTO_PROVIDER, BaseCryptoProvider):
160
+ _default_provider = config.CRYPTO_PROVIDER
161
+ return _default_provider
162
+ elif callable(config.CRYPTO_PROVIDER):
163
+ _default_provider = config.CRYPTO_PROVIDER()
164
+ return _default_provider
165
+
166
+ _default_provider = AESGCMCryptoProvider()
167
+ return _default_provider
168
+
169
+
170
+ def reset_crypto_provider():
171
+ """Resets the singleton crypto provider (useful for testing)."""
172
+ global _default_provider
173
+ _default_provider = None
dataman/core/fields.py ADDED
@@ -0,0 +1,120 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from django.db import models
5
+
6
+ from dataman.core.crypto import get_crypto_provider
7
+
8
+
9
+ class EncryptedFieldMixin:
10
+ """Mixin for transparent field-level encryption on Django model fields."""
11
+
12
+ def __init__(self, *args, **kwargs):
13
+ # Encrypted ciphertext strings can exceed typical CharField lengths
14
+ if "max_length" in kwargs and kwargs["max_length"] is not None:
15
+ # Ensure sufficient DB column width for base64 nonce + ciphertext + tag
16
+ kwargs["max_length"] = max(kwargs["max_length"], 512)
17
+ super().__init__(*args, **kwargs)
18
+
19
+ def from_db_value(self, value: str | None, expression, connection) -> Any:
20
+ if value is None:
21
+ return None
22
+ return self._decrypt_value(value)
23
+
24
+ def to_python(self, value: Any) -> Any:
25
+ if value is None:
26
+ return None
27
+ if isinstance(value, str) and value.startswith("v1:"):
28
+ return self._decrypt_value(value)
29
+ return super().to_python(value)
30
+
31
+ def get_prep_value(self, value: Any) -> str | None:
32
+ value = super().get_prep_value(value)
33
+ if value is None:
34
+ return None
35
+ if isinstance(value, str) and value.startswith("v1:"):
36
+ return value
37
+ return self._encrypt_value(value)
38
+
39
+ def _encrypt_value(self, raw_value: Any) -> str:
40
+ provider = get_crypto_provider()
41
+ val_str = str(raw_value)
42
+ return provider.encrypt(val_str)
43
+
44
+ def _decrypt_value(self, cipher_value: str) -> Any:
45
+ provider = get_crypto_provider()
46
+ decrypted = provider.decrypt(cipher_value)
47
+ return super().to_python(decrypted)
48
+
49
+ def deconstruct(self):
50
+ name, path, args, kwargs = super().deconstruct()
51
+ # Point to dataman.core.fields
52
+ if path.startswith("dataman.core.fields"):
53
+ pass
54
+ return name, path, args, kwargs
55
+
56
+
57
+ class EncryptedCharField(EncryptedFieldMixin, models.CharField):
58
+ """CharField that is encrypted in the database using AES-256-GCM."""
59
+
60
+ def __init__(self, *args, **kwargs):
61
+ if "max_length" not in kwargs or kwargs["max_length"] is None:
62
+ kwargs["max_length"] = 512
63
+ super().__init__(*args, **kwargs)
64
+
65
+
66
+ class EncryptedTextField(EncryptedFieldMixin, models.TextField):
67
+ """TextField that is encrypted in the database using AES-256-GCM."""
68
+
69
+ pass
70
+
71
+
72
+ class EncryptedEmailField(EncryptedFieldMixin, models.EmailField):
73
+ """EmailField that is encrypted in the database using AES-256-GCM."""
74
+
75
+ def __init__(self, *args, **kwargs):
76
+ if "max_length" not in kwargs or kwargs["max_length"] is None:
77
+ kwargs["max_length"] = 512
78
+ super().__init__(*args, **kwargs)
79
+
80
+
81
+ class EncryptedJSONField(EncryptedFieldMixin, models.TextField):
82
+ """JSONField stored as encrypted text in the database."""
83
+
84
+ def from_db_value(self, value: str | None, expression, connection) -> Any:
85
+ if value is None:
86
+ return None
87
+ decrypted_str = self._decrypt_value(value)
88
+ if decrypted_str is None or decrypted_str == "":
89
+ return None
90
+ try:
91
+ return json.loads(decrypted_str)
92
+ except Exception:
93
+ return decrypted_str
94
+
95
+ def to_python(self, value: Any) -> Any:
96
+ if value is None:
97
+ return None
98
+ if isinstance(value, (dict, list)):
99
+ return value
100
+ if isinstance(value, str) and value.startswith("v1:"):
101
+ decrypted_str = self._decrypt_value(value)
102
+ try:
103
+ return json.loads(decrypted_str)
104
+ except Exception:
105
+ return decrypted_str
106
+ if isinstance(value, str):
107
+ try:
108
+ return json.loads(value)
109
+ except Exception:
110
+ return value
111
+ return value
112
+
113
+ def get_prep_value(self, value: Any) -> str | None:
114
+ if value is None:
115
+ return None
116
+ if isinstance(value, str) and value.startswith("v1:"):
117
+ return value
118
+ if not isinstance(value, str):
119
+ value = json.dumps(value)
120
+ return self._encrypt_value(value)
@@ -0,0 +1,107 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+
5
+ def mask_partial(val: Any, keep_last: int = 4, mask_char: str = "*") -> str:
6
+ """
7
+ Masks all alphanumeric characters except the last `keep_last` characters,
8
+ preserving formatting characters such as hyphens and spaces.
9
+ Example: '123-45-6789' -> '***-**-6789'
10
+ """
11
+ if val is None:
12
+ return None
13
+ s = str(val)
14
+ if not s:
15
+ return s
16
+
17
+ # Find indices of alphanumeric characters
18
+ alnum_indices = [i for i, c in enumerate(s) if c.isalnum()]
19
+ if len(alnum_indices) <= keep_last:
20
+ return mask_char * len(s)
21
+
22
+ mask_set = set(alnum_indices[:-keep_last])
23
+ return "".join(mask_char if i in mask_set else c for i, c in enumerate(s))
24
+
25
+
26
+ def mask_last4(val: Any, mask_char: str = "*") -> str:
27
+ """
28
+ Masks payment cards / identifiers, retaining only the last 4 digits.
29
+ Example: '4111-2222-3333-4444' -> '****-****-****-4444'
30
+ """
31
+ return mask_partial(val, keep_last=4, mask_char=mask_char)
32
+
33
+
34
+ def mask_email(val: Any, mask_char: str = "*") -> str:
35
+ """
36
+ Masks email addresses while retaining the domain and outer username boundaries.
37
+ Example: 'vikash@example.com' -> 'v***h@example.com'
38
+ """
39
+ if val is None:
40
+ return None
41
+ s = str(val).strip()
42
+ if "@" not in s:
43
+ return mask_partial(s, keep_last=2, mask_char=mask_char)
44
+
45
+ parts = s.split("@", 1)
46
+ username, domain = parts[0], parts[1]
47
+
48
+ if len(username) <= 1:
49
+ masked_user = mask_char * 3
50
+ elif len(username) == 2:
51
+ masked_user = f"{username[0]}{mask_char * 3}"
52
+ elif len(username) <= 4:
53
+ masked_user = f"{username[0]}{mask_char * 3}{username[-1]}"
54
+ else:
55
+ masked_user = f"{username[0]}{mask_char * (len(username) - 2)}{username[-1]}"
56
+
57
+ return f"{masked_user}@{domain}"
58
+
59
+
60
+ def mask_phone(val: Any, mask_char: str = "*") -> str:
61
+ """
62
+ Masks telephone numbers, keeping the last 4 digits and country prefix if present.
63
+ Example: '+1 (555) 234-5678' -> '+1 (***) ***-5678'
64
+ """
65
+ if val is None:
66
+ return None
67
+ s = str(val)
68
+ return mask_partial(s, keep_last=4, mask_char=mask_char)
69
+
70
+
71
+ def mask_full(val: Any, mask_char: str = "*") -> str:
72
+ """
73
+ Completely masks the value with asterisks.
74
+ Example: 'sensitive_secret' -> '********'
75
+ """
76
+ if val is None:
77
+ return None
78
+ return mask_char * 8
79
+
80
+
81
+ STRATEGY_MAP: dict[str, Callable[[Any], str]] = {
82
+ "partial": mask_partial,
83
+ "ssn": mask_partial,
84
+ "last4": mask_last4,
85
+ "card": mask_last4,
86
+ "credit_card": mask_last4,
87
+ "email": mask_email,
88
+ "email_mask": mask_email,
89
+ "phone": mask_phone,
90
+ "full": mask_full,
91
+ "redact": mask_full,
92
+ }
93
+
94
+
95
+ def mask_value(val: Any, strategy: str | Callable[[Any], str] = "partial") -> Any:
96
+ """
97
+ Applies the requested masking strategy to a value.
98
+ """
99
+ if val is None or val == "":
100
+ return val
101
+
102
+ if callable(strategy):
103
+ return strategy(val)
104
+
105
+ strategy_key = str(strategy).lower().strip()
106
+ mask_fn = STRATEGY_MAP.get(strategy_key, mask_partial)
107
+ return mask_fn(val)