altcodepro-polydb-python 2.3.11__tar.gz → 2.3.13__tar.gz

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.
Files changed (84) hide show
  1. {altcodepro_polydb_python-2.3.11/src/altcodepro_polydb_python.egg-info → altcodepro_polydb_python-2.3.13}/PKG-INFO +1 -1
  2. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/pyproject.toml +1 -1
  3. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13/src/altcodepro_polydb_python.egg-info}/PKG-INFO +1 -1
  4. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/AzureTableStorageAdapter.py +8 -2
  5. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/PostgreSQLAdapter.py +25 -15
  6. altcodepro_polydb_python-2.3.13/src/polydb/audit/AuditStorage.py +164 -0
  7. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/audit/models.py +41 -6
  8. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/query.py +9 -6
  9. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/retry.py +23 -12
  10. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/security.py +7 -4
  11. altcodepro_polydb_python-2.3.11/src/polydb/audit/AuditStorage.py +0 -136
  12. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/LICENSE +0 -0
  13. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/MANIFEST.in +0 -0
  14. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/README.md +0 -0
  15. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/requirements-aws.txt +0 -0
  16. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/requirements-azure.txt +0 -0
  17. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/requirements-dev.txt +0 -0
  18. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/requirements-gcp.txt +0 -0
  19. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/requirements-generic.txt +0 -0
  20. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/requirements.txt +0 -0
  21. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/setup.cfg +0 -0
  22. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/setup.py +0 -0
  23. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/altcodepro_polydb_python.egg-info/SOURCES.txt +0 -0
  24. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/altcodepro_polydb_python.egg-info/dependency_links.txt +0 -0
  25. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/altcodepro_polydb_python.egg-info/requires.txt +0 -0
  26. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/altcodepro_polydb_python.egg-info/top_level.txt +0 -0
  27. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/PolyDB.py +0 -0
  28. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/__init__.py +0 -0
  29. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/AzureBlobStorageAdapter.py +0 -0
  30. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/AzureFileStorageAdapter.py +0 -0
  31. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/AzureQueueAdapter.py +0 -0
  32. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/BlockchainBlobAdapter.py +0 -0
  33. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/BlockchainFileAdapter.py +0 -0
  34. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/BlockchainKVAdapter.py +0 -0
  35. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/BlockchainQueueAdapter.py +0 -0
  36. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/DynamoDBAdapter.py +0 -0
  37. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/EFSAdapter.py +0 -0
  38. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/FirestoreAdapter.py +0 -0
  39. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/GCPFilestoreAdapter.py +0 -0
  40. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/GCPPubSubAdapter.py +0 -0
  41. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/GCPStorageAdapter.py +0 -0
  42. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/MongoDBAdapter.py +0 -0
  43. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/S3Adapter.py +0 -0
  44. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/S3CompatibleAdapter.py +0 -0
  45. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/SQSAdapter.py +0 -0
  46. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/VercelBlobAdapter.py +0 -0
  47. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/VercelFileAdapter.py +0 -0
  48. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/VercelKVAdapter.py +0 -0
  49. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/VercelQueueAdapter.py +0 -0
  50. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/adapters/__init__.py +0 -0
  51. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/advanced_query.py +0 -0
  52. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/audit/__init__.py +0 -0
  53. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/audit/context.py +0 -0
  54. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/audit/manager.py +0 -0
  55. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/base/NoSQLKVAdapter.py +0 -0
  56. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/base/ObjectStorageAdapter.py +0 -0
  57. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/base/QueueAdapter.py +0 -0
  58. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/base/SharedFilesAdapter.py +0 -0
  59. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/base/__init__.py +0 -0
  60. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/batch.py +0 -0
  61. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/cache.py +0 -0
  62. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/cloudDatabaseFactory.py +0 -0
  63. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/databaseFactory.py +0 -0
  64. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/decorators.py +0 -0
  65. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/errors.py +0 -0
  66. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/json_safe.py +0 -0
  67. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/models.py +0 -0
  68. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/monitoring.py +0 -0
  69. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/multitenancy.py +0 -0
  70. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/py.typed +0 -0
  71. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/registry.py +0 -0
  72. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/schema.py +0 -0
  73. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/types.py +0 -0
  74. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/utils.py +0 -0
  75. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/src/polydb/validation.py +0 -0
  76. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_aws.py +0 -0
  77. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_azure.py +0 -0
  78. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_blockchain.py +0 -0
  79. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_cloud_factory.py +0 -0
  80. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_gcp.py +0 -0
  81. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_mongodb.py +0 -0
  82. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_multi_engine.py +0 -0
  83. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_postgresql.py +0 -0
  84. {altcodepro_polydb_python-2.3.11 → altcodepro_polydb_python-2.3.13}/tests/test_vercel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.3.11
3
+ Version: 2.3.13
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "altcodepro-polydb-python"
7
- version = "2.3.11"
7
+ version = "2.3.13"
8
8
  description = "Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: altcodepro-polydb-python
3
- Version: 2.3.11
3
+ Version: 2.3.13
4
4
  Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
5
5
  Author: AltCodePro
6
6
  Project-URL: Homepage, https://github.com/altcodepro/polydb-python
@@ -116,11 +116,17 @@ class AzureTableStorageAdapter(NoSQLKVAdapter):
116
116
  if v is None:
117
117
  return None
118
118
 
119
+ # Treat empty containers as absent — let the row omit the property.
120
+ if isinstance(v, (list, tuple, dict)) and len(v) == 0:
121
+ return None
122
+
119
123
  if isinstance(v, bytes):
120
124
  return _BYTES_PREFIX + base64.b64encode(v).decode("ascii")
121
125
 
122
- if isinstance(v, (dict, list)):
123
- return _JSON_PREFIX + json.dumps(v, default=json_safe)
126
+ if isinstance(v, (dict, list, tuple)):
127
+ return _JSON_PREFIX + json.dumps(
128
+ list(v) if isinstance(v, tuple) else v, default=json_safe
129
+ )
124
130
 
125
131
  if isinstance(v, UUID):
126
132
  return str(v)
@@ -8,8 +8,6 @@ import hashlib
8
8
  from contextlib import contextmanager
9
9
  import json
10
10
  from datetime import datetime, date
11
-
12
-
13
11
  from ..errors import DatabaseError, ConnectionError
14
12
  from ..retry import retry
15
13
  from ..utils import validate_table_name, validate_column_name
@@ -102,34 +100,46 @@ class PostgreSQLAdapter:
102
100
 
103
101
  def _serialize_value(self, v: Any) -> Any:
104
102
  """
105
- Make all outgoing values safe for psycopg2.
103
+ Make outgoing values safe for psycopg2 across mixed column types.
106
104
 
107
105
  Rules:
108
- - dict -> Json()
109
- - list -> leave as list (so TEXT[] works)
110
- - datetime/date -> pass as native (psycopg2 handles it)
111
- - Decimal -> convert to float
112
- - everything else -> pass as-is
106
+ None / empty list / empty dict -> None (becomes NULL on any column)
107
+ list of primitives (str/int/...) -> native list (psycopg2 maps to TEXT[]/INT[])
108
+ list containing dicts -> Json(list) (for JSONB columns)
109
+ dict -> Json(dict)
110
+ datetime/date -> native
111
+ Decimal -> float
112
+ everything else -> as-is
113
113
  """
114
+ # NULL-ify empties so they're valid for TEXT[], JSONB, and plain columns alike.
114
115
  from psycopg2.extras import Json
115
116
 
116
117
  if v is None:
117
118
  return None
119
+ if isinstance(v, (list, tuple)) and len(v) == 0:
120
+ return None
121
+ if isinstance(v, dict) and len(v) == 0:
122
+ return None
118
123
 
119
- # Dict -> JSON/JSONB
124
+ # Dict -> JSONB
120
125
  if isinstance(v, dict):
121
126
  return Json(self._json_safe(v))
122
127
 
123
- # List:
124
- # DO NOT wrap in Json() automatically.
125
- # If column is JSONB, Postgres will still accept Json(list).
126
- # But for TEXT[] columns we must send Python list.
127
- if isinstance(v, list):
128
+ # List: route by element type.
129
+ if isinstance(v, (list, tuple)):
130
+ v = list(v)
131
+ # If ANY element is a dict, treat as JSON payload (for JSONB columns).
132
+ if any(isinstance(x, dict) for x in v):
133
+ return Json(v)
134
+ # If ALL elements are primitives, send as native list for TEXT[]/INT[].
135
+ if all(isinstance(x, (str, int, float, bool, type(None))) for x in v):
136
+ return v
137
+ # Mixed / nested -> safest is JSONB
128
138
  return Json(v)
129
139
 
130
140
  # Datetime / date
131
141
  if isinstance(v, (datetime, date)):
132
- return v # psycopg2 handles natively
142
+ return v
133
143
 
134
144
  # Decimal
135
145
  if isinstance(v, Decimal):
@@ -0,0 +1,164 @@
1
+ # src/polydb/audit/AuditStorage.py
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ from typing import Optional, Dict, Any
6
+
7
+ from .models import AuditRecord
8
+ from ..cloudDatabaseFactory import CloudDatabaseFactory
9
+
10
+
11
+ class AuditStorage:
12
+ """Audit log with distributed-safe hash chaining"""
13
+
14
+ _lock = threading.Lock()
15
+
16
+ def __init__(self):
17
+ self.factory = CloudDatabaseFactory()
18
+ self.sql = self.factory.get_sql()
19
+ self._ensure_table()
20
+
21
+ @staticmethod
22
+ def _is_unique_violation(exc: Exception) -> bool:
23
+ s = str(exc).lower()
24
+ return "23505" in s or "duplicate key" in s or "unique constraint" in s
25
+
26
+ def _ensure_table(self):
27
+ """Create audit table if not exists"""
28
+ try:
29
+ schema = """
30
+ CREATE TABLE IF NOT EXISTS polydb_audit_log (
31
+ audit_id UUID PRIMARY KEY,
32
+ timestamp TIMESTAMP NOT NULL,
33
+ tenant_id VARCHAR(255),
34
+ actor_id VARCHAR(255),
35
+ roles TEXT[],
36
+ action VARCHAR(50) NOT NULL,
37
+ model VARCHAR(255) NOT NULL,
38
+ entity_id VARCHAR(255),
39
+ storage_type VARCHAR(20) NOT NULL,
40
+ provider VARCHAR(50) NOT NULL,
41
+ success BOOLEAN NOT NULL,
42
+ before JSONB,
43
+ after JSONB,
44
+ changed_fields TEXT[],
45
+ trace_id VARCHAR(255),
46
+ request_id VARCHAR(255),
47
+ ip_address VARCHAR(45),
48
+ user_agent TEXT,
49
+ error TEXT,
50
+ hash VARCHAR(64) NOT NULL,
51
+ previous_hash VARCHAR(64) NOT NULL DEFAULT '',
52
+ CONSTRAINT uq_audit_chain UNIQUE (tenant_id, previous_hash),
53
+ created_at TIMESTAMP DEFAULT NOW()
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_audit_tenant_timestamp
57
+ ON polydb_audit_log(tenant_id, timestamp DESC);
58
+ CREATE INDEX IF NOT EXISTS idx_audit_model_entity
59
+ ON polydb_audit_log(model, entity_id);
60
+ CREATE INDEX IF NOT EXISTS idx_audit_actor
61
+ ON polydb_audit_log(actor_id, timestamp DESC);
62
+ CREATE INDEX IF NOT EXISTS idx_audit_hash_chain
63
+ ON polydb_audit_log(tenant_id, timestamp DESC, previous_hash);
64
+ """
65
+
66
+ self.sql.execute(schema)
67
+ except Exception:
68
+ # Table may already exist
69
+ pass
70
+
71
+ def get_last_hash(self, tenant_id: Optional[str]) -> Optional[str]:
72
+ """Get most recent hash with strict ordering (distributed-safe)"""
73
+ with self._lock:
74
+ try:
75
+ from ..query import QueryBuilder, Operator
76
+
77
+ builder = QueryBuilder()
78
+
79
+ if tenant_id is not None:
80
+ builder.where("tenant_id", Operator.EQ, tenant_id)
81
+
82
+ builder.order_by("timestamp", descending=True).take(1)
83
+
84
+ results = self.sql.query_linq("polydb_audit_log", builder)
85
+
86
+ if results and len(results) > 0:
87
+ return results[0].get("hash")
88
+
89
+ return None
90
+ except Exception:
91
+ return None
92
+
93
+ def persist(self, record: AuditRecord) -> None:
94
+ """Append to the hash chain. Concurrency-safe ACROSS PROCESSES via the
95
+ UNIQUE(tenant_id, previous_hash) constraint + bounded retry: if two
96
+ writers race on the same predecessor, the loser re-reads the new tail
97
+ and re-chains instead of forking. (threading.Lock alone was only
98
+ process-local — the old "distributed-safe" claim was false.)"""
99
+ from dataclasses import asdict
100
+ from .models import compute_audit_hash
101
+
102
+ last_err: Optional[Exception] = None
103
+ for _ in range(8):
104
+ with self._lock:
105
+ prev = self.get_last_hash(record.tenant_id) or ""
106
+ record.previous_hash = prev
107
+ record.hash = compute_audit_hash(asdict(record))
108
+ row = {
109
+ "audit_id": record.audit_id,
110
+ "timestamp": record.timestamp,
111
+ "tenant_id": record.tenant_id,
112
+ "actor_id": record.actor_id,
113
+ "roles": record.roles or None,
114
+ "action": record.action,
115
+ "model": record.model,
116
+ "entity_id": record.entity_id,
117
+ "storage_type": record.storage_type,
118
+ "provider": record.provider,
119
+ "success": record.success,
120
+ "before": record.before,
121
+ "after": record.after,
122
+ "changed_fields": record.changed_fields or None,
123
+ "trace_id": record.trace_id,
124
+ "request_id": record.request_id,
125
+ "ip_address": record.ip_address,
126
+ "user_agent": record.user_agent,
127
+ "error": record.error,
128
+ "hash": record.hash,
129
+ "previous_hash": record.previous_hash,
130
+ }
131
+ try:
132
+ self.sql.insert("polydb_audit_log", row)
133
+ return
134
+ except Exception as e:
135
+ if self._is_unique_violation(e):
136
+ last_err = e
137
+ continue
138
+ raise
139
+ raise last_err or RuntimeError("audit persist failed after retries")
140
+
141
+ def verify_chain(self, tenant_id: Optional[str] = None) -> bool:
142
+ """Verify BOTH chain linkage AND per-record content integrity.
143
+ The old version only checked previous_hash linkage, so editing
144
+ before/after/action while leaving `hash` intact passed silently."""
145
+ from ..query import QueryBuilder, Operator
146
+ from .models import compute_audit_hash
147
+
148
+ builder = QueryBuilder()
149
+ if tenant_id is not None:
150
+ builder.where("tenant_id", Operator.EQ, tenant_id)
151
+ builder.order_by("timestamp", descending=False)
152
+
153
+ records = self.sql.query_linq("polydb_audit_log", builder)
154
+ if not records:
155
+ return True
156
+
157
+ prev = ""
158
+ for r in records:
159
+ if (r.get("previous_hash") or "") != prev:
160
+ return False
161
+ if r.get("hash") != compute_audit_hash(r): # content tamper check
162
+ return False
163
+ prev = r.get("hash")
164
+ return True
@@ -1,15 +1,52 @@
1
1
  # src/polydb/audit/models.py
2
2
 
3
3
  from dataclasses import dataclass, asdict
4
- from datetime import datetime
5
4
  from typing import Any, Dict, List, Optional
6
5
  import uuid
7
6
  import hashlib
8
7
  import json
9
-
8
+ from datetime import datetime, timezone
10
9
  from ..json_safe import json_safe
11
10
 
12
11
 
12
+ def _iso(ts: Any) -> str:
13
+ return ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
14
+
15
+
16
+ def canonical_audit_payload(src: Dict[str, Any]) -> str:
17
+ """Deterministic JSON for the hash chain. Identical whether `src` is a
18
+ freshly-built record (asdict) or a row read back from Postgres, so the
19
+ create-time hash and the verify-time recomputed hash match.
20
+ Normalizes [] vs NULL and timestamp formatting; excludes `hash`."""
21
+ payload = {
22
+ "audit_id": src.get("audit_id"),
23
+ "timestamp": _iso(src.get("timestamp")),
24
+ "tenant_id": src.get("tenant_id"),
25
+ "actor_id": src.get("actor_id"),
26
+ "roles": list(src.get("roles") or []),
27
+ "action": src.get("action"),
28
+ "model": src.get("model"),
29
+ "entity_id": src.get("entity_id"),
30
+ "storage_type": src.get("storage_type"),
31
+ "provider": src.get("provider"),
32
+ "success": bool(src.get("success")),
33
+ "before": src.get("before"),
34
+ "after": src.get("after"),
35
+ "changed_fields": list(src.get("changed_fields") or []),
36
+ "trace_id": src.get("trace_id"),
37
+ "request_id": src.get("request_id"),
38
+ "ip_address": src.get("ip_address"),
39
+ "user_agent": src.get("user_agent"),
40
+ "error": src.get("error"),
41
+ "previous_hash": src.get("previous_hash") or "",
42
+ }
43
+ return json.dumps(payload, sort_keys=True, default=json_safe)
44
+
45
+
46
+ def compute_audit_hash(src: Dict[str, Any]) -> str:
47
+ return hashlib.sha256(canonical_audit_payload(src).encode()).hexdigest()
48
+
49
+
13
50
  @dataclass
14
51
  class AuditRecord:
15
52
  audit_id: str
@@ -55,7 +92,7 @@ class AuditRecord:
55
92
  context,
56
93
  previous_hash: Optional[str] = None,
57
94
  ):
58
- now = datetime.utcnow().isoformat()
95
+ now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
59
96
  audit_id = str(uuid.uuid4())
60
97
 
61
98
  record = cls(
@@ -81,8 +118,6 @@ class AuditRecord:
81
118
  previous_hash=previous_hash,
82
119
  )
83
120
 
84
- record.hash = hashlib.sha256(
85
- json.dumps(asdict(record), sort_keys=True,default=json_safe).encode()
86
- ).hexdigest()
121
+ record.hash = compute_audit_hash(asdict(record))
87
122
 
88
123
  return record
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
  from dataclasses import dataclass, field
5
5
  from typing import Any, Dict, List, Optional, Union
6
6
  from enum import Enum
7
+ from .utils import validate_column_name
7
8
 
8
9
 
9
10
  class Operator(Enum):
@@ -130,6 +131,7 @@ class QueryBuilder:
130
131
  params = []
131
132
 
132
133
  for f in self.filters:
134
+ validate_column_name(f.field)
133
135
 
134
136
  if f.operator == Operator.EQ:
135
137
  clauses.append(f"{f.field} = %s")
@@ -156,14 +158,15 @@ class QueryBuilder:
156
158
  params.append(f.value)
157
159
 
158
160
  elif f.operator == Operator.IN:
159
-
160
161
  if isinstance(f.value, (list, tuple)):
161
- placeholders = ",".join(["%s"] * len(f.value))
162
- clauses.append(f"{f.field} IN ({placeholders})")
163
- params.extend(f.value)
164
-
162
+ if not f.value:
163
+ clauses.append("1=0") # empty IN → match nothing
164
+ else:
165
+ placeholders = ",".join(["%s"] * len(f.value))
166
+ clauses.append(f"{f.field} IN ({placeholders})")
167
+ params.extend(f.value)
165
168
  else:
166
- clauses.append(f"{f.field} LIKE %s")
169
+ clauses.append(f"{f.field} = %s") # scalar IN == equality
167
170
  params.append(f.value)
168
171
 
169
172
  elif f.operator == Operator.NOT_IN:
@@ -12,42 +12,49 @@ from typing import Callable, Optional, Tuple, Type
12
12
  # Metrics hooks for enterprise monitoring
13
13
  class MetricsHooks:
14
14
  """Metrics hooks that users can override for monitoring"""
15
-
15
+
16
16
  @staticmethod
17
17
  def on_query_start(operation: str, **kwargs):
18
18
  """Called when query starts"""
19
19
  pass
20
-
20
+
21
21
  @staticmethod
22
22
  def on_query_end(operation: str, duration: float, success: bool, **kwargs):
23
23
  """Called when query ends"""
24
24
  pass
25
-
25
+
26
26
  @staticmethod
27
27
  def on_error(operation: str, error: Exception, **kwargs):
28
28
  """Called when error occurs"""
29
29
  pass
30
30
 
31
31
 
32
- def retry(max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0,
33
- exceptions: Tuple[Type[Exception], ...] = (Exception,)):
32
+ def retry(
33
+ max_attempts: int = 3,
34
+ delay: float = 1.0,
35
+ backoff: float = 2.0,
36
+ exceptions: Tuple[Type[Exception], ...] = (Exception,),
37
+ ):
34
38
  """
35
39
  Retry decorator with exponential backoff
36
-
40
+
37
41
  Args:
38
42
  max_attempts: Maximum number of retry attempts
39
43
  delay: Initial delay between retries (seconds)
40
44
  backoff: Backoff multiplier
41
45
  exceptions: Tuple of exceptions to catch
42
46
  """
47
+ if max_attempts < 1:
48
+ raise ValueError("max_attempts must be >= 1")
49
+
43
50
  def decorator(func: Callable) -> Callable:
44
51
  @wraps(func)
45
52
  def wrapper(*args, **kwargs):
46
53
  attempt = 0
47
54
  current_delay = delay
48
-
55
+
49
56
  logger = logging.getLogger(__name__)
50
-
57
+
51
58
  while attempt < max_attempts:
52
59
  start_time = time.time()
53
60
  try:
@@ -61,16 +68,20 @@ def retry(max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0,
61
68
  duration = time.time() - start_time
62
69
  MetricsHooks.on_query_end(func.__name__, duration, False)
63
70
  MetricsHooks.on_error(func.__name__, e)
64
-
71
+
65
72
  if attempt >= max_attempts:
66
73
  raise
67
-
74
+
68
75
  logger.warning(
69
76
  f"Attempt {attempt}/{max_attempts} failed for {func.__name__}: {str(e)}. "
70
77
  f"Retrying in {current_delay}s..."
71
78
  )
72
79
  time.sleep(current_delay)
73
80
  current_delay *= backoff
74
-
81
+ raise RuntimeError(
82
+ f"{func.__name__} exhausted {max_attempts} attempts without returning"
83
+ )
84
+
75
85
  return wrapper
76
- return decorator
86
+
87
+ return decorator
@@ -2,6 +2,7 @@
2
2
  """
3
3
  Security features: encryption, masking, row-level security
4
4
  """
5
+
5
6
  from typing import Dict, Any, List, Optional, Callable, Union
6
7
  from dataclasses import dataclass
7
8
  import hashlib
@@ -50,7 +51,7 @@ class FieldEncryption:
50
51
  """Encrypt arbitrary value (serialize if non-str)"""
51
52
  if value is None:
52
53
  return ""
53
- data = json.dumps(value,default=json_safe) if not isinstance(value, str) else value
54
+ data = json.dumps(value, default=json_safe) if not isinstance(value, str) else value
54
55
  try:
55
56
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
56
57
 
@@ -91,9 +92,11 @@ class FieldEncryption:
91
92
  except ImportError:
92
93
  raise ImportError("cryptography not installed")
93
94
  except Exception as e:
94
- # On decrypt failure, return masked or original to avoid crashes
95
- logger.warning(f"Decryption failed: {e}. Returning original value.")
96
- return encrypted_data
95
+ # Fail loud. Returning ciphertext as if it were plaintext masks
96
+ # key-rotation errors / corruption and leaks the 'encrypted:' blob
97
+ # into application data.
98
+ logger.error("Field decryption failed: %s", e)
99
+ raise
97
100
 
98
101
  def encrypt_fields(self, data: Dict[str, Any], fields: List[str]) -> Dict[str, Any]:
99
102
  """Encrypt specified fields in data dict"""
@@ -1,136 +0,0 @@
1
- # src/polydb/audit/AuditStorage.py
2
- from __future__ import annotations
3
-
4
- import threading
5
- from typing import Optional, Dict, Any
6
-
7
- from .models import AuditRecord
8
- from ..cloudDatabaseFactory import CloudDatabaseFactory
9
-
10
-
11
- class AuditStorage:
12
- """Audit log with distributed-safe hash chaining"""
13
-
14
- _lock = threading.Lock()
15
-
16
- def __init__(self):
17
- self.factory = CloudDatabaseFactory()
18
- self.sql = self.factory.get_sql()
19
- self._ensure_table()
20
-
21
- def _ensure_table(self):
22
- """Create audit table if not exists"""
23
- try:
24
- schema = """
25
- CREATE TABLE IF NOT EXISTS polydb_audit_log (
26
- audit_id UUID PRIMARY KEY,
27
- timestamp TIMESTAMP NOT NULL,
28
- tenant_id VARCHAR(255),
29
- actor_id VARCHAR(255),
30
- roles TEXT[],
31
- action VARCHAR(50) NOT NULL,
32
- model VARCHAR(255) NOT NULL,
33
- entity_id VARCHAR(255),
34
- storage_type VARCHAR(20) NOT NULL,
35
- provider VARCHAR(50) NOT NULL,
36
- success BOOLEAN NOT NULL,
37
- before JSONB,
38
- after JSONB,
39
- changed_fields TEXT[],
40
- trace_id VARCHAR(255),
41
- request_id VARCHAR(255),
42
- ip_address VARCHAR(45),
43
- user_agent TEXT,
44
- error TEXT,
45
- hash VARCHAR(64) NOT NULL,
46
- previous_hash VARCHAR(64),
47
- created_at TIMESTAMP DEFAULT NOW()
48
- );
49
-
50
- CREATE INDEX IF NOT EXISTS idx_audit_tenant_timestamp
51
- ON polydb_audit_log(tenant_id, timestamp DESC);
52
- CREATE INDEX IF NOT EXISTS idx_audit_model_entity
53
- ON polydb_audit_log(model, entity_id);
54
- CREATE INDEX IF NOT EXISTS idx_audit_actor
55
- ON polydb_audit_log(actor_id, timestamp DESC);
56
- CREATE INDEX IF NOT EXISTS idx_audit_hash_chain
57
- ON polydb_audit_log(tenant_id, timestamp DESC, previous_hash);
58
- """
59
-
60
- self.sql.execute(schema)
61
- except Exception:
62
- # Table may already exist
63
- pass
64
-
65
- def get_last_hash(self, tenant_id: Optional[str]) -> Optional[str]:
66
- """Get most recent hash with strict ordering (distributed-safe)"""
67
- with self._lock:
68
- try:
69
- from ..query import QueryBuilder, Operator
70
-
71
- builder = QueryBuilder()
72
-
73
- if tenant_id is not None:
74
- builder.where('tenant_id', Operator.EQ, tenant_id)
75
-
76
- builder.order_by('timestamp', descending=True).take(1)
77
-
78
- results = self.sql.query_linq('polydb_audit_log', builder)
79
-
80
- if results and len(results) > 0:
81
- return results[0].get('hash')
82
-
83
- return None
84
- except Exception:
85
- return None
86
-
87
- def persist(self, record: AuditRecord) -> None:
88
- """Persist with lock to ensure chain integrity"""
89
- with self._lock:
90
- self.sql.insert('polydb_audit_log', {
91
- 'audit_id': record.audit_id,
92
- 'timestamp': record.timestamp,
93
- 'tenant_id': record.tenant_id,
94
- 'actor_id': record.actor_id,
95
- 'roles': record.roles,
96
- 'action': record.action,
97
- 'model': record.model,
98
- 'entity_id': record.entity_id,
99
- 'storage_type': record.storage_type,
100
- 'provider': record.provider,
101
- 'success': record.success,
102
- 'before': record.before,
103
- 'after': record.after,
104
- 'changed_fields': record.changed_fields,
105
- 'trace_id': record.trace_id,
106
- 'request_id': record.request_id,
107
- 'ip_address': record.ip_address,
108
- 'user_agent': record.user_agent,
109
- 'error': record.error,
110
- 'hash': record.hash,
111
- 'previous_hash': record.previous_hash,
112
- })
113
-
114
- def verify_chain(self, tenant_id: Optional[str] = None) -> bool:
115
- """Verify hash chain integrity"""
116
- from ..query import QueryBuilder, Operator
117
-
118
- builder = QueryBuilder()
119
-
120
- if tenant_id is not None:
121
- builder.where('tenant_id', Operator.EQ, tenant_id)
122
-
123
- builder.order_by('timestamp', descending=False)
124
-
125
- records = self.sql.query_linq('polydb_audit_log', builder)
126
-
127
- if not records:
128
- return True
129
-
130
- prev_hash = None
131
- for record in records:
132
- if record.get('previous_hash') != prev_hash:
133
- return False
134
- prev_hash = record.get('hash')
135
-
136
- return True