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.
- dataman/__init__.py +18 -0
- dataman/cli.py +731 -0
- dataman/core/__init__.py +0 -0
- dataman/core/apps.py +9 -0
- dataman/core/audit.py +171 -0
- dataman/core/auth.py +106 -0
- dataman/core/crypto.py +173 -0
- dataman/core/fields.py +120 -0
- dataman/core/masking.py +107 -0
- dataman/core/middleware.py +46 -0
- dataman/core/models.py +175 -0
- dataman/core/permissions.py +110 -0
- dataman/core/router.py +112 -0
- dataman/core/signals.py +56 -0
- dataman/core/templates/admin/dashboard.html +1399 -0
- dataman/core/templates/admin/login.html +377 -0
- dataman/core/urls.py +472 -0
- dataman/core/views.py +678 -0
- dataman/django_setup.py +192 -0
- dataman/py.typed +0 -0
- dataman_engine-0.1.0.dist-info/METADATA +259 -0
- dataman_engine-0.1.0.dist-info/RECORD +24 -0
- dataman_engine-0.1.0.dist-info/WHEEL +4 -0
- dataman_engine-0.1.0.dist-info/entry_points.txt +3 -0
dataman/core/__init__.py
ADDED
|
File without changes
|
dataman/core/apps.py
ADDED
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)
|
dataman/core/masking.py
ADDED
|
@@ -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)
|