altcodepro-polydb-python 2.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.
- altcodepro_polydb_python-2.1.0.dist-info/METADATA +378 -0
- altcodepro_polydb_python-2.1.0.dist-info/RECORD +51 -0
- altcodepro_polydb_python-2.1.0.dist-info/WHEEL +5 -0
- altcodepro_polydb_python-2.1.0.dist-info/licenses/LICENSE +21 -0
- altcodepro_polydb_python-2.1.0.dist-info/top_level.txt +1 -0
- polydb/__init__.py +64 -0
- polydb/adapters/AzureBlobStorageAdapter.py +77 -0
- polydb/adapters/AzureFileStorageAdapter.py +79 -0
- polydb/adapters/AzureQueueAdapter.py +61 -0
- polydb/adapters/AzureTableStorageAdapter.py +182 -0
- polydb/adapters/DynamoDBAdapter.py +216 -0
- polydb/adapters/EFSAdapter.py +50 -0
- polydb/adapters/FirestoreAdapter.py +193 -0
- polydb/adapters/GCPStorageAdapter.py +81 -0
- polydb/adapters/MongoDBAdapter.py +136 -0
- polydb/adapters/PostgreSQLAdapter.py +453 -0
- polydb/adapters/PubSubAdapter.py +83 -0
- polydb/adapters/S3Adapter.py +86 -0
- polydb/adapters/S3CompatibleAdapter.py +90 -0
- polydb/adapters/SQSAdapter.py +84 -0
- polydb/adapters/VercelKVAdapter.py +327 -0
- polydb/adapters/__init__.py +0 -0
- polydb/advanced_query.py +147 -0
- polydb/audit/AuditStorage.py +136 -0
- polydb/audit/__init__.py +7 -0
- polydb/audit/context.py +53 -0
- polydb/audit/manager.py +47 -0
- polydb/audit/models.py +86 -0
- polydb/base/NoSQLKVAdapter.py +301 -0
- polydb/base/ObjectStorageAdapter.py +42 -0
- polydb/base/QueueAdapter.py +27 -0
- polydb/base/SharedFilesAdapter.py +32 -0
- polydb/base/__init__.py +0 -0
- polydb/batch.py +163 -0
- polydb/cache.py +204 -0
- polydb/databaseFactory.py +748 -0
- polydb/decorators.py +21 -0
- polydb/errors.py +82 -0
- polydb/factory.py +107 -0
- polydb/models.py +39 -0
- polydb/monitoring.py +313 -0
- polydb/multitenancy.py +197 -0
- polydb/py.typed +0 -0
- polydb/query.py +150 -0
- polydb/registry.py +71 -0
- polydb/retry.py +76 -0
- polydb/schema.py +205 -0
- polydb/security.py +458 -0
- polydb/types.py +127 -0
- polydb/utils.py +61 -0
- polydb/validation.py +131 -0
polydb/security.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# src/polydb/security.py
|
|
2
|
+
"""
|
|
3
|
+
Security features: encryption, masking, row-level security
|
|
4
|
+
"""
|
|
5
|
+
from typing import Dict, Any, List, Optional, Callable, Union
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import hashlib
|
|
8
|
+
import base64
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
from functools import wraps
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class EncryptionConfig:
|
|
19
|
+
"""Field-level encryption configuration"""
|
|
20
|
+
|
|
21
|
+
encrypted_fields: List[str]
|
|
22
|
+
key: bytes
|
|
23
|
+
algorithm: str = "AES-256-GCM"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FieldEncryption:
|
|
27
|
+
"""Field-level encryption for sensitive data"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, encryption_key: Optional[bytes] = None):
|
|
30
|
+
self.encryption_key = encryption_key or self._generate_key()
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _generate_key() -> bytes:
|
|
34
|
+
"""Generate encryption key from environment or create new"""
|
|
35
|
+
key_str = os.getenv("POLYDB_ENCRYPTION_KEY")
|
|
36
|
+
if key_str:
|
|
37
|
+
return base64.b64decode(key_str)
|
|
38
|
+
|
|
39
|
+
# Generate new key (should be saved securely)
|
|
40
|
+
key = os.urandom(32)
|
|
41
|
+
# For production, log or store this key securely; here we just warn
|
|
42
|
+
logger.warning(
|
|
43
|
+
"Generated new encryption key. Store it securely in POLYDB_ENCRYPTION_KEY env var."
|
|
44
|
+
)
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
def _encrypt_value(self, value: Any) -> str:
|
|
48
|
+
"""Encrypt arbitrary value (serialize if non-str)"""
|
|
49
|
+
if value is None:
|
|
50
|
+
return ""
|
|
51
|
+
data = json.dumps(value) if not isinstance(value, str) else value
|
|
52
|
+
try:
|
|
53
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
54
|
+
|
|
55
|
+
aesgcm = AESGCM(self.encryption_key)
|
|
56
|
+
nonce = os.urandom(12)
|
|
57
|
+
|
|
58
|
+
ciphertext = aesgcm.encrypt(nonce, data.encode("utf-8"), None)
|
|
59
|
+
|
|
60
|
+
# Combine nonce and ciphertext
|
|
61
|
+
encrypted = base64.b64encode(nonce + ciphertext).decode("utf-8")
|
|
62
|
+
return f"encrypted:{encrypted}"
|
|
63
|
+
except ImportError:
|
|
64
|
+
raise ImportError("cryptography not installed. Install with: pip install cryptography")
|
|
65
|
+
|
|
66
|
+
def _decrypt_value(self, encrypted_data: Any) -> Any:
|
|
67
|
+
"""Decrypt field data (deserialize if needed)"""
|
|
68
|
+
if not isinstance(encrypted_data, str) or not encrypted_data.startswith("encrypted:"):
|
|
69
|
+
return encrypted_data
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
73
|
+
|
|
74
|
+
encrypted_data = encrypted_data[10:] # Remove prefix
|
|
75
|
+
combined = base64.b64decode(encrypted_data)
|
|
76
|
+
|
|
77
|
+
nonce = combined[:12]
|
|
78
|
+
ciphertext = combined[12:]
|
|
79
|
+
|
|
80
|
+
aesgcm = AESGCM(self.encryption_key)
|
|
81
|
+
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext, None)
|
|
82
|
+
plaintext = plaintext_bytes.decode("utf-8")
|
|
83
|
+
|
|
84
|
+
# Attempt to parse as JSON if it looks like JSON
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(plaintext)
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
return plaintext
|
|
89
|
+
except ImportError:
|
|
90
|
+
raise ImportError("cryptography not installed")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
# On decrypt failure, return masked or original to avoid crashes
|
|
93
|
+
logger.warning(f"Decryption failed: {e}. Returning original value.")
|
|
94
|
+
return encrypted_data
|
|
95
|
+
|
|
96
|
+
def encrypt_fields(self, data: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
|
|
97
|
+
"""Encrypt specified fields in data dict"""
|
|
98
|
+
result = dict(data)
|
|
99
|
+
|
|
100
|
+
for field in fields:
|
|
101
|
+
if field in result and result[field] is not None:
|
|
102
|
+
result[field] = self._encrypt_value(result[field])
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
def decrypt_fields(self, data: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
|
|
107
|
+
"""Decrypt specified fields in data dict"""
|
|
108
|
+
result = dict(data)
|
|
109
|
+
|
|
110
|
+
for field in fields:
|
|
111
|
+
if field in result and result[field] is not None:
|
|
112
|
+
result[field] = self._decrypt_value(result[field])
|
|
113
|
+
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class DataMasking:
|
|
118
|
+
"""Data masking for sensitive information"""
|
|
119
|
+
|
|
120
|
+
def __init__(self):
|
|
121
|
+
# Model-specific masking configs: {model: {field: mask_type}}
|
|
122
|
+
self._configs: Dict[str, Dict[str, str]] = {}
|
|
123
|
+
# Global field-based rules (fallback)
|
|
124
|
+
self._global_rules = {
|
|
125
|
+
"email": self._mask_email,
|
|
126
|
+
"phone": self._mask_phone,
|
|
127
|
+
"ssn": self._mask_ssn,
|
|
128
|
+
"credit_card": self._mask_credit_card,
|
|
129
|
+
"password": lambda x: "[REDACTED]",
|
|
130
|
+
"ssn_*": self._mask_ssn, # Pattern match
|
|
131
|
+
"card_*": self._mask_credit_card,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def register_model_config(self, model: str, config: Dict[str, str]):
|
|
135
|
+
"""Register masking config for a model: {field: mask_type}"""
|
|
136
|
+
self._configs[model] = config
|
|
137
|
+
|
|
138
|
+
def _infer_mask_type(self, field: str) -> Optional[Callable]:
|
|
139
|
+
"""Infer masker based on field name (global fallback)"""
|
|
140
|
+
for pattern, masker in self._global_rules.items():
|
|
141
|
+
if pattern.endswith("_*") and field.startswith(pattern[:-2]):
|
|
142
|
+
return masker
|
|
143
|
+
if pattern == field:
|
|
144
|
+
return masker
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _mask_email(email: str) -> str:
|
|
149
|
+
"""Mask email address"""
|
|
150
|
+
if "@" not in email:
|
|
151
|
+
return email
|
|
152
|
+
|
|
153
|
+
local, domain = email.split("@", 1)
|
|
154
|
+
if len(local) <= 2:
|
|
155
|
+
masked_local = "*" * len(local)
|
|
156
|
+
else:
|
|
157
|
+
masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
|
|
158
|
+
|
|
159
|
+
return f"{masked_local}@{domain}"
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _mask_phone(phone: str) -> str:
|
|
163
|
+
"""Mask phone number"""
|
|
164
|
+
phone = "".join(filter(str.isdigit, str(phone)))
|
|
165
|
+
if len(phone) <= 4:
|
|
166
|
+
return "*" * len(phone)
|
|
167
|
+
return "*" * (len(phone) - 4) + phone[-4:]
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _mask_ssn(ssn: str) -> str:
|
|
171
|
+
"""Mask SSN"""
|
|
172
|
+
ssn = "".join(filter(str.isdigit, str(ssn)))
|
|
173
|
+
if len(ssn) >= 4:
|
|
174
|
+
return "***-**-" + ssn[-4:]
|
|
175
|
+
return "*" * len(ssn)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _mask_credit_card(cc: str) -> str:
|
|
179
|
+
"""Mask credit card"""
|
|
180
|
+
cc = "".join(filter(str.isdigit, str(cc).replace("-", "").replace(" ", "")))
|
|
181
|
+
if len(cc) <= 4:
|
|
182
|
+
return "*" * len(cc)
|
|
183
|
+
return "**** **** **** " + cc[-4:]
|
|
184
|
+
|
|
185
|
+
def mask(
|
|
186
|
+
self,
|
|
187
|
+
data: Dict[str, Any],
|
|
188
|
+
model: str,
|
|
189
|
+
actor_id: Optional[str] = None,
|
|
190
|
+
tenant_id: Optional[str] = None,
|
|
191
|
+
) -> Dict[str, Any]:
|
|
192
|
+
"""
|
|
193
|
+
Apply masking to data based on model, actor, and tenant context.
|
|
194
|
+
|
|
195
|
+
Uses model-specific config if available, falls back to global rules.
|
|
196
|
+
Context can be used for dynamic masking (e.g., actor-specific).
|
|
197
|
+
"""
|
|
198
|
+
result = dict(data)
|
|
199
|
+
config = self._configs.get(model, {})
|
|
200
|
+
|
|
201
|
+
for field, value in list(result.items()):
|
|
202
|
+
if value is None:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Model-specific
|
|
206
|
+
mask_type = config.get(field)
|
|
207
|
+
if mask_type:
|
|
208
|
+
masker = self._get_masker(mask_type)
|
|
209
|
+
if masker:
|
|
210
|
+
result[field] = masker(str(value))
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Global inference
|
|
214
|
+
inferred_masker = self._infer_mask_type(field)
|
|
215
|
+
if inferred_masker:
|
|
216
|
+
result[field] = inferred_masker(str(value))
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Context-based dynamic masking (e.g., hide all if not owner)
|
|
220
|
+
if actor_id and tenant_id:
|
|
221
|
+
result = self._apply_context_masking(result, actor_id, tenant_id, model)
|
|
222
|
+
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
def _get_masker(self, mask_type: str) -> Optional[Callable]:
|
|
226
|
+
"""Get masker function by type"""
|
|
227
|
+
maskers = {
|
|
228
|
+
"email": self._mask_email,
|
|
229
|
+
"phone": self._mask_phone,
|
|
230
|
+
"ssn": self._mask_ssn,
|
|
231
|
+
"credit_card": self._mask_credit_card,
|
|
232
|
+
"redact": lambda x: "[REDACTED]",
|
|
233
|
+
}
|
|
234
|
+
return maskers.get(mask_type)
|
|
235
|
+
|
|
236
|
+
def _apply_context_masking(
|
|
237
|
+
self, data: Dict[str, Any], actor_id: str, tenant_id: str, model: str
|
|
238
|
+
) -> Dict[str, Any]:
|
|
239
|
+
"""Apply additional masking based on context (override in subclasses)"""
|
|
240
|
+
# Example: Mask sensitive fields if not tenant owner
|
|
241
|
+
# This is a placeholder; customize per app
|
|
242
|
+
if "owner_id" in data and data["owner_id"] != actor_id:
|
|
243
|
+
for sensitive in ["salary", "health_info"]:
|
|
244
|
+
if sensitive in data:
|
|
245
|
+
data[sensitive] = "[RESTRICTED]"
|
|
246
|
+
return data
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class Policy:
|
|
251
|
+
"""RLS Policy entry"""
|
|
252
|
+
|
|
253
|
+
name: str
|
|
254
|
+
func: Callable[[Dict[str, Any], Dict[str, Any]], bool]
|
|
255
|
+
apply_to: str # 'read', 'write', or 'both'
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class RowLevelSecurity:
|
|
259
|
+
"""Row-level security policies"""
|
|
260
|
+
|
|
261
|
+
def __init__(self):
|
|
262
|
+
self.policies: Dict[str, List[Policy]] = {} # {model: [Policy instances]}
|
|
263
|
+
self._read_filters: Dict[str, Dict[str, Any]] = {} # {model: default_query_filters}
|
|
264
|
+
self._write_filters: Dict[str, Dict[str, Any]] = {} # {model: write_constraints}
|
|
265
|
+
|
|
266
|
+
def add_policy(
|
|
267
|
+
self,
|
|
268
|
+
model: str,
|
|
269
|
+
name: str,
|
|
270
|
+
policy_func: Callable[[Dict[str, Any], Dict[str, Any]], bool],
|
|
271
|
+
apply_to: str = "read", # 'read', 'write', or 'both'
|
|
272
|
+
):
|
|
273
|
+
"""
|
|
274
|
+
Add RLS policy
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
model: Model name
|
|
278
|
+
name: Policy name (for logging/debug)
|
|
279
|
+
policy_func: Function(row_or_data, context) -> bool
|
|
280
|
+
apply_to: 'read' (post-filter), 'write' (pre-check), or 'both'
|
|
281
|
+
"""
|
|
282
|
+
if model not in self.policies:
|
|
283
|
+
self.policies[model] = []
|
|
284
|
+
|
|
285
|
+
# Ensure unique names
|
|
286
|
+
existing_names = {p.name for p in self.policies[model]}
|
|
287
|
+
if name in existing_names:
|
|
288
|
+
raise ValueError(f"Policy '{name}' already exists for model '{model}'")
|
|
289
|
+
|
|
290
|
+
policy = Policy(name=name, func=policy_func, apply_to=apply_to)
|
|
291
|
+
self.policies[model].append(policy)
|
|
292
|
+
|
|
293
|
+
def _get_context(
|
|
294
|
+
self,
|
|
295
|
+
actor_id: Optional[str] = None,
|
|
296
|
+
tenant_id: Optional[str] = None,
|
|
297
|
+
roles: Optional[List[str]] = None,
|
|
298
|
+
) -> Dict[str, Any]:
|
|
299
|
+
"""Get current security context"""
|
|
300
|
+
return {
|
|
301
|
+
"actor_id": actor_id,
|
|
302
|
+
"tenant_id": tenant_id,
|
|
303
|
+
"roles": roles or [],
|
|
304
|
+
"timestamp": os.getenv("REQUEST_TIMESTAMP", ""), # Optional: from request
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def check_access(
|
|
308
|
+
self,
|
|
309
|
+
model: str,
|
|
310
|
+
item: Union[Dict[str, Any], Any], # row for read, data for write
|
|
311
|
+
context: Optional[Dict[str, Any]] = None,
|
|
312
|
+
operation: str = "read", # 'read' or 'write'
|
|
313
|
+
) -> bool:
|
|
314
|
+
"""Check if access allowed for item under context and operation"""
|
|
315
|
+
if model not in self.policies:
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
ctx = context or self._get_context()
|
|
319
|
+
for policy in self.policies[model]:
|
|
320
|
+
if operation in policy.apply_to or policy.apply_to == "both":
|
|
321
|
+
if not policy.func(item, ctx):
|
|
322
|
+
# Log denial (in production, use logger)
|
|
323
|
+
logger.info(f"RLS denied: {policy.name} for {model}:{operation}")
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
def enforce_read(
|
|
329
|
+
self, model: str, query: Dict[str, Any], context: Optional[Dict[str, Any]] = None
|
|
330
|
+
) -> Dict[str, Any]:
|
|
331
|
+
"""
|
|
332
|
+
Enforce RLS on read query: add pre-filters if possible, else return original (post-filter later)
|
|
333
|
+
|
|
334
|
+
For simplicity, adds default filters from _read_filters; complex policies use post-filter.
|
|
335
|
+
"""
|
|
336
|
+
result = dict(query or {})
|
|
337
|
+
|
|
338
|
+
# Add static filters (e.g., tenant_id)
|
|
339
|
+
if model in self._read_filters:
|
|
340
|
+
for k, v in self._read_filters[model].items():
|
|
341
|
+
if k not in result:
|
|
342
|
+
result[k] = v
|
|
343
|
+
|
|
344
|
+
# Dynamic: if context provided, inject (e.g., tenant_id)
|
|
345
|
+
ctx = context or self._get_context()
|
|
346
|
+
if "tenant_id" in ctx and ctx["tenant_id"] and "tenant_id" not in result:
|
|
347
|
+
result["tenant_id"] = ctx["tenant_id"]
|
|
348
|
+
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
def filter_results(
|
|
352
|
+
self, model: str, rows: List[Dict[str, Any]], context: Optional[Dict[str, Any]] = None
|
|
353
|
+
) -> List[Dict[str, Any]]:
|
|
354
|
+
"""Post-query filter based on RLS policies"""
|
|
355
|
+
if model not in self.policies:
|
|
356
|
+
return rows
|
|
357
|
+
|
|
358
|
+
ctx = context or self._get_context()
|
|
359
|
+
return [row for row in rows if self.check_access(model, row, ctx, operation="read")]
|
|
360
|
+
|
|
361
|
+
def enforce_write(
|
|
362
|
+
self, model: str, data: Dict[str, Any], context: Optional[Dict[str, Any]] = None
|
|
363
|
+
) -> Dict[str, Any]:
|
|
364
|
+
"""
|
|
365
|
+
Enforce RLS on write: validate data against policies, inject constraints
|
|
366
|
+
"""
|
|
367
|
+
# First, check if write is allowed
|
|
368
|
+
ctx = context or self._get_context()
|
|
369
|
+
if not self.check_access(model, data, ctx, operation="write"):
|
|
370
|
+
raise PermissionError(f"RLS write denied for model '{model}'")
|
|
371
|
+
|
|
372
|
+
result = dict(data)
|
|
373
|
+
|
|
374
|
+
# Inject constraints (e.g., set tenant_id if not present)
|
|
375
|
+
if model in self._write_filters:
|
|
376
|
+
for k, v in self._write_filters[model].items():
|
|
377
|
+
if k not in result:
|
|
378
|
+
result[k] = v
|
|
379
|
+
|
|
380
|
+
# Dynamic injection
|
|
381
|
+
if "tenant_id" in ctx and ctx["tenant_id"] and "tenant_id" not in result:
|
|
382
|
+
result["tenant_id"] = ctx["tenant_id"]
|
|
383
|
+
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
def set_default_filters(
|
|
387
|
+
self, model: str, read_filters: Dict[str, Any], write_filters: Dict[str, Any]
|
|
388
|
+
):
|
|
389
|
+
"""Set default query filters/constraints for model"""
|
|
390
|
+
if read_filters:
|
|
391
|
+
self._read_filters[model] = read_filters
|
|
392
|
+
if write_filters:
|
|
393
|
+
self._write_filters[model] = write_filters
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# Example RLS policies
|
|
397
|
+
def tenant_isolation_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
|
398
|
+
"""Ensure users only access their tenant's data"""
|
|
399
|
+
tenant_id = context.get("tenant_id")
|
|
400
|
+
if not tenant_id:
|
|
401
|
+
return False
|
|
402
|
+
return item.get("tenant_id") == tenant_id
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def role_based_policy(row: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
|
406
|
+
"""Check role-based access"""
|
|
407
|
+
required_role = row.get("required_role")
|
|
408
|
+
user_roles = context.get("roles", [])
|
|
409
|
+
|
|
410
|
+
if not required_role:
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
return required_role in user_roles
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def ownership_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
|
417
|
+
"""Ensure users only access/modify their owned data"""
|
|
418
|
+
actor_id = context.get("actor_id")
|
|
419
|
+
if not actor_id:
|
|
420
|
+
return False
|
|
421
|
+
return item.get("owner_id") == actor_id or item.get("created_by") == actor_id
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def sensitivity_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
|
425
|
+
"""Restrict access to sensitive data based on role"""
|
|
426
|
+
sensitivity_level = item.get("sensitivity_level", "low")
|
|
427
|
+
user_roles = context.get("roles", [])
|
|
428
|
+
if sensitivity_level == "high" and "admin" not in user_roles:
|
|
429
|
+
return False
|
|
430
|
+
if sensitivity_level == "medium" and "admin" not in user_roles and "editor" not in user_roles:
|
|
431
|
+
return False
|
|
432
|
+
return True
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def time_based_policy(item: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
|
436
|
+
"""Restrict access based on data age (e.g., archive old data)"""
|
|
437
|
+
from datetime import datetime, timedelta
|
|
438
|
+
|
|
439
|
+
created_at = item.get("created_at")
|
|
440
|
+
if not created_at:
|
|
441
|
+
return True
|
|
442
|
+
try:
|
|
443
|
+
created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
444
|
+
if datetime.now(created_dt.tzinfo) - created_dt > timedelta(days=365):
|
|
445
|
+
return "archivist" in context.get("roles", [])
|
|
446
|
+
except ValueError:
|
|
447
|
+
pass
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# Example usage (in app init):
|
|
452
|
+
# rls = RowLevelSecurity()
|
|
453
|
+
# rls.add_policy('User', 'tenant_isolation', tenant_isolation_policy, apply_to='both')
|
|
454
|
+
# rls.add_policy('User', 'ownership', ownership_policy, apply_to='both')
|
|
455
|
+
# rls.add_policy('User', 'role_based', role_based_policy, apply_to='read')
|
|
456
|
+
# rls.add_policy('Document', 'sensitivity', sensitivity_policy, apply_to='read')
|
|
457
|
+
# rls.add_policy('Log', 'time_based', time_based_policy, apply_to='read')
|
|
458
|
+
# rls.set_default_filters('User', read_filters={'status': 'active'}, write_filters={'status': 'active'})
|
polydb/types.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# src/polydb/types.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, List, Optional, Protocol, Tuple, Union, runtime_checkable, Literal
|
|
6
|
+
|
|
7
|
+
from .query import QueryBuilder
|
|
8
|
+
|
|
9
|
+
StorageType = Literal["sql", "nosql"]
|
|
10
|
+
|
|
11
|
+
JsonDict = Dict[str, Any]
|
|
12
|
+
Lookup = Dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ModelMeta:
|
|
17
|
+
"""Model metadata"""
|
|
18
|
+
storage: StorageType
|
|
19
|
+
table: Optional[str] = None
|
|
20
|
+
collection: Optional[str] = None
|
|
21
|
+
pk_field: Optional[str] = None
|
|
22
|
+
rk_field: Optional[str] = None
|
|
23
|
+
provider: Optional[str] = None
|
|
24
|
+
cache: bool = False
|
|
25
|
+
cache_ttl: Optional[int] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class SQLAdapter(Protocol):
|
|
30
|
+
"""SQL adapter contract"""
|
|
31
|
+
|
|
32
|
+
def insert(self, table: str, data: JsonDict) -> JsonDict: ...
|
|
33
|
+
|
|
34
|
+
def select(
|
|
35
|
+
self,
|
|
36
|
+
table: str,
|
|
37
|
+
query: Optional[Lookup] = None,
|
|
38
|
+
limit: Optional[int] = None,
|
|
39
|
+
offset: Optional[int] = None
|
|
40
|
+
) -> List[JsonDict]: ...
|
|
41
|
+
|
|
42
|
+
def select_page(
|
|
43
|
+
self,
|
|
44
|
+
table: str,
|
|
45
|
+
query: Lookup,
|
|
46
|
+
page_size: int,
|
|
47
|
+
continuation_token: Optional[str] = None
|
|
48
|
+
) -> Tuple[List[JsonDict], Optional[str]]: ...
|
|
49
|
+
|
|
50
|
+
def update(
|
|
51
|
+
self,
|
|
52
|
+
table: str,
|
|
53
|
+
entity_id: Union[Any, Lookup],
|
|
54
|
+
data: JsonDict
|
|
55
|
+
) -> JsonDict: ...
|
|
56
|
+
|
|
57
|
+
def upsert(self, table: str, data: JsonDict) -> JsonDict: ...
|
|
58
|
+
|
|
59
|
+
def delete(
|
|
60
|
+
self,
|
|
61
|
+
table: str,
|
|
62
|
+
entity_id: Union[Any, Lookup]
|
|
63
|
+
) -> JsonDict: ...
|
|
64
|
+
|
|
65
|
+
def query_linq(
|
|
66
|
+
self,
|
|
67
|
+
table: str,
|
|
68
|
+
builder: QueryBuilder
|
|
69
|
+
) -> Union[List[JsonDict], int]: ...
|
|
70
|
+
|
|
71
|
+
def execute(self, sql: str, params: Optional[List[Any]] = None) -> None: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@runtime_checkable
|
|
75
|
+
class NoSQLKVAdapter(Protocol):
|
|
76
|
+
"""NoSQL KV adapter contract"""
|
|
77
|
+
|
|
78
|
+
def put(self, model: type, data: JsonDict) -> JsonDict: ...
|
|
79
|
+
|
|
80
|
+
def query(
|
|
81
|
+
self,
|
|
82
|
+
model: type,
|
|
83
|
+
query: Optional[Lookup] = None,
|
|
84
|
+
limit: Optional[int] = None,
|
|
85
|
+
no_cache: bool = False,
|
|
86
|
+
cache_ttl: Optional[int] = None
|
|
87
|
+
) -> List[JsonDict]: ...
|
|
88
|
+
|
|
89
|
+
def query_page(
|
|
90
|
+
self,
|
|
91
|
+
model: type,
|
|
92
|
+
query: Lookup,
|
|
93
|
+
page_size: int,
|
|
94
|
+
continuation_token: Optional[str] = None
|
|
95
|
+
) -> Tuple[List[JsonDict], Optional[str]]: ...
|
|
96
|
+
|
|
97
|
+
def patch(
|
|
98
|
+
self,
|
|
99
|
+
model: type,
|
|
100
|
+
entity_id: Union[Any, Lookup],
|
|
101
|
+
data: JsonDict,
|
|
102
|
+
*,
|
|
103
|
+
etag: Optional[str] = None,
|
|
104
|
+
replace: bool = False
|
|
105
|
+
) -> JsonDict: ...
|
|
106
|
+
|
|
107
|
+
def upsert(
|
|
108
|
+
self,
|
|
109
|
+
model: type,
|
|
110
|
+
data: JsonDict,
|
|
111
|
+
*,
|
|
112
|
+
replace: bool = False
|
|
113
|
+
) -> JsonDict: ...
|
|
114
|
+
|
|
115
|
+
def delete(
|
|
116
|
+
self,
|
|
117
|
+
model: type,
|
|
118
|
+
entity_id: Union[Any, Lookup],
|
|
119
|
+
*,
|
|
120
|
+
etag: Optional[str] = None
|
|
121
|
+
) -> JsonDict: ...
|
|
122
|
+
|
|
123
|
+
def query_linq(
|
|
124
|
+
self,
|
|
125
|
+
model: type,
|
|
126
|
+
builder: QueryBuilder
|
|
127
|
+
) -> Union[List[JsonDict], int]: ...
|
polydb/utils.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# src/polydb/utils.py
|
|
2
|
+
"""
|
|
3
|
+
Utility functions for validation and logging
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, Any
|
|
9
|
+
from .errors import ValidationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_table_name(table: str) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Validate table name to prevent SQL injection
|
|
15
|
+
Only allows alphanumeric, underscore, and hyphen
|
|
16
|
+
"""
|
|
17
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', table):
|
|
18
|
+
raise ValidationError(
|
|
19
|
+
f"Invalid table name: '{table}'. Only alphanumeric, underscore, and hyphen allowed."
|
|
20
|
+
)
|
|
21
|
+
return table
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_column_name(column: str) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Validate column name to prevent SQL injection
|
|
27
|
+
Only allows alphanumeric and underscore
|
|
28
|
+
"""
|
|
29
|
+
if not re.match(r'^[a-zA-Z0-9_]+$', column):
|
|
30
|
+
raise ValidationError(
|
|
31
|
+
f"Invalid column name: '{column}'. Only alphanumeric and underscore allowed."
|
|
32
|
+
)
|
|
33
|
+
return column
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_columns(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
37
|
+
"""
|
|
38
|
+
Validate all column names in data dictionary
|
|
39
|
+
"""
|
|
40
|
+
for key in data.keys():
|
|
41
|
+
validate_column_name(key)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def setup_logger(name: str, level: int = logging.INFO) -> logging.Logger:
|
|
46
|
+
"""Setup logger with consistent format"""
|
|
47
|
+
logger = logging.getLogger(name)
|
|
48
|
+
logger.setLevel(level)
|
|
49
|
+
|
|
50
|
+
# Clear existing handlers to avoid duplication in multiprocess scenarios
|
|
51
|
+
if logger.hasHandlers():
|
|
52
|
+
logger.handlers.clear()
|
|
53
|
+
|
|
54
|
+
handler = logging.StreamHandler()
|
|
55
|
+
formatter = logging.Formatter(
|
|
56
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
57
|
+
)
|
|
58
|
+
handler.setFormatter(formatter)
|
|
59
|
+
logger.addHandler(handler)
|
|
60
|
+
|
|
61
|
+
return logger
|