altcodepro-polydb-python 2.3.20__py3-none-any.whl → 2.3.22__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.3.20.dist-info → altcodepro_polydb_python-2.3.22.dist-info}/METADATA +1 -1
- {altcodepro_polydb_python-2.3.20.dist-info → altcodepro_polydb_python-2.3.22.dist-info}/RECORD +13 -8
- polydb/__init__.py +2 -0
- polydb/adapters/PostgreSQLAdapter.py +300 -191
- polydb/errors.py +6 -0
- polydb/observability/__init__.py +3 -0
- polydb/observability/logging.py +124 -0
- polydb/services/__init__.py +9 -0
- polydb/services/compliance_service.py +141 -0
- polydb/services/security_service.py +133 -0
- {altcodepro_polydb_python-2.3.20.dist-info → altcodepro_polydb_python-2.3.22.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.3.20.dist-info → altcodepro_polydb_python-2.3.22.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.3.20.dist-info → altcodepro_polydb_python-2.3.22.dist-info}/top_level.txt +0 -0
polydb/errors.py
CHANGED
|
@@ -80,3 +80,9 @@ class OperationNotSupportedError(PolyDBError):
|
|
|
80
80
|
"""Raised when an adapter cannot perform a requested operation."""
|
|
81
81
|
|
|
82
82
|
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class InsufficientBalanceError(PolyDBError):
|
|
86
|
+
"""Raised when an atomic decrement is attempted but the balance is insufficient."""
|
|
87
|
+
|
|
88
|
+
pass
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured JSON logging for PolyDB.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from polydb.observability.logging import configure_logging, set_polydb_log_context
|
|
7
|
+
|
|
8
|
+
configure_logging()
|
|
9
|
+
set_polydb_log_context(tenant_id="acme", model="User", operation="select")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextvars
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Context variables
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
_ctx_tenant_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
26
|
+
"polydb_tenant_id", default=None
|
|
27
|
+
)
|
|
28
|
+
_ctx_model: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
29
|
+
"polydb_model", default=None
|
|
30
|
+
)
|
|
31
|
+
_ctx_operation: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
32
|
+
"polydb_operation", default=None
|
|
33
|
+
)
|
|
34
|
+
_ctx_duration_ms: contextvars.ContextVar[Optional[float]] = contextvars.ContextVar(
|
|
35
|
+
"polydb_duration_ms", default=None
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_polydb_log_context(
|
|
40
|
+
*,
|
|
41
|
+
tenant_id: Optional[str] = None,
|
|
42
|
+
model: Optional[str] = None,
|
|
43
|
+
operation: Optional[str] = None,
|
|
44
|
+
duration_ms: Optional[float] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Set per-request context fields that are injected into every log record."""
|
|
47
|
+
if tenant_id is not None:
|
|
48
|
+
_ctx_tenant_id.set(tenant_id)
|
|
49
|
+
if model is not None:
|
|
50
|
+
_ctx_model.set(model)
|
|
51
|
+
if operation is not None:
|
|
52
|
+
_ctx_operation.set(operation)
|
|
53
|
+
if duration_ms is not None:
|
|
54
|
+
_ctx_duration_ms.set(duration_ms)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# JSON Formatter
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _PolyDBJsonFormatter(logging.Formatter):
|
|
63
|
+
"""Emit each log record as a single-line JSON object."""
|
|
64
|
+
|
|
65
|
+
def format(self, record: logging.LogRecord) -> str: # type: ignore[override]
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"timestamp": self.formatTime(record, self.datefmt),
|
|
68
|
+
"level": record.levelname,
|
|
69
|
+
"logger": record.name,
|
|
70
|
+
"message": record.getMessage(),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Inject context vars
|
|
74
|
+
tenant_id = _ctx_tenant_id.get()
|
|
75
|
+
if tenant_id is not None:
|
|
76
|
+
payload["tenant_id"] = tenant_id
|
|
77
|
+
|
|
78
|
+
model = _ctx_model.get()
|
|
79
|
+
if model is not None:
|
|
80
|
+
payload["model"] = model
|
|
81
|
+
|
|
82
|
+
operation = _ctx_operation.get()
|
|
83
|
+
if operation is not None:
|
|
84
|
+
payload["operation"] = operation
|
|
85
|
+
|
|
86
|
+
duration_ms = _ctx_duration_ms.get()
|
|
87
|
+
if duration_ms is not None:
|
|
88
|
+
payload["duration_ms"] = duration_ms
|
|
89
|
+
|
|
90
|
+
# Attach any extra fields the caller passed directly to the record
|
|
91
|
+
for key in ("operation", "table", "duration_ms", "tenant_id", "model"):
|
|
92
|
+
val = getattr(record, key, None)
|
|
93
|
+
if val is not None:
|
|
94
|
+
payload[key] = val
|
|
95
|
+
|
|
96
|
+
if record.exc_info:
|
|
97
|
+
payload["exc_info"] = self.formatException(record.exc_info)
|
|
98
|
+
|
|
99
|
+
return json.dumps(payload, default=str)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# configure_logging
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def configure_logging(level: Optional[str] = None) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Configure root / polydb logger to emit structured JSON.
|
|
110
|
+
|
|
111
|
+
Reads ``POLYDB_LOG_LEVEL`` env var (default ``INFO``).
|
|
112
|
+
"""
|
|
113
|
+
resolved_level = level or os.getenv("POLYDB_LOG_LEVEL", "INFO").upper()
|
|
114
|
+
numeric_level = getattr(logging, resolved_level, logging.INFO)
|
|
115
|
+
|
|
116
|
+
handler = logging.StreamHandler()
|
|
117
|
+
handler.setFormatter(_PolyDBJsonFormatter())
|
|
118
|
+
|
|
119
|
+
polydb_logger = logging.getLogger("polydb")
|
|
120
|
+
polydb_logger.setLevel(numeric_level)
|
|
121
|
+
|
|
122
|
+
# Replace existing handlers to avoid duplicates
|
|
123
|
+
polydb_logger.handlers = [handler]
|
|
124
|
+
polydb_logger.propagate = False
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
polydb.services.compliance_service
|
|
3
|
+
====================================
|
|
4
|
+
Compliance audit log writer with retry queue and CRITICAL-level alerting.
|
|
5
|
+
|
|
6
|
+
Fix M14: audit failures were silently swallowed (except Exception: logger.warning).
|
|
7
|
+
This implementation:
|
|
8
|
+
- Emits a compliance.audit_failure metric counter on first failure
|
|
9
|
+
- Enqueues a retry in an async queue
|
|
10
|
+
- After MAX_RETRIES consecutive failures logs at CRITICAL and calls the alert fn
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("polydb.services.compliance")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AuditEvent:
|
|
26
|
+
event_type: str
|
|
27
|
+
tenant_id: str
|
|
28
|
+
actor_id: str
|
|
29
|
+
resource_type: str
|
|
30
|
+
resource_id: str
|
|
31
|
+
action: str
|
|
32
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
attempt: int = 0
|
|
34
|
+
created_at: float = field(default_factory=time.time)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
WriterFn = Callable[[AuditEvent], Awaitable[None]]
|
|
38
|
+
MetricsFn = Callable[[str, Dict[str, str]], None]
|
|
39
|
+
AlertFn = Callable[[AuditEvent], Awaitable[None]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ComplianceService:
|
|
43
|
+
"""Audit log writer with async retry queue and escalation."""
|
|
44
|
+
|
|
45
|
+
MAX_RETRIES = 3
|
|
46
|
+
RETRY_DELAY_S = 2.0
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
metrics_fn: Optional[MetricsFn] = None,
|
|
52
|
+
alert_fn: Optional[AlertFn] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._metrics = metrics_fn
|
|
55
|
+
self._alert = alert_fn
|
|
56
|
+
self._retry_queue: asyncio.Queue[AuditEvent] = asyncio.Queue(maxsize=10_000)
|
|
57
|
+
self._running = False
|
|
58
|
+
self._worker_task: Optional[asyncio.Task] = None
|
|
59
|
+
|
|
60
|
+
async def log(self, event: AuditEvent, *, writer: WriterFn) -> None:
|
|
61
|
+
"""Write audit event. On failure, enqueue for retry."""
|
|
62
|
+
try:
|
|
63
|
+
await writer(event)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
self._record_failure(event, exc, step="initial")
|
|
66
|
+
event.attempt = 1
|
|
67
|
+
try:
|
|
68
|
+
self._retry_queue.put_nowait(event)
|
|
69
|
+
except asyncio.QueueFull:
|
|
70
|
+
logger.critical(
|
|
71
|
+
"compliance.audit_failure retry queue full — event dropped: "
|
|
72
|
+
"event_type=%s tenant_id=%s",
|
|
73
|
+
event.event_type, event.tenant_id,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def start(self, *, writer: WriterFn) -> None:
|
|
77
|
+
"""Start the background retry worker. Call once at service startup."""
|
|
78
|
+
self._running = True
|
|
79
|
+
self._worker_task = asyncio.create_task(
|
|
80
|
+
self._drain_retry_queue(writer=writer),
|
|
81
|
+
name="compliance_retry_worker",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def stop(self) -> None:
|
|
85
|
+
self._running = False
|
|
86
|
+
if self._worker_task:
|
|
87
|
+
self._worker_task.cancel()
|
|
88
|
+
|
|
89
|
+
async def _drain_retry_queue(self, *, writer: WriterFn) -> None:
|
|
90
|
+
while self._running:
|
|
91
|
+
try:
|
|
92
|
+
event = await asyncio.wait_for(self._retry_queue.get(), timeout=5.0)
|
|
93
|
+
except asyncio.TimeoutError:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
if event.attempt >= self.MAX_RETRIES:
|
|
97
|
+
logger.critical(
|
|
98
|
+
"compliance.audit_failure UNRECOVERABLE after %d retries: "
|
|
99
|
+
"event_type=%s tenant_id=%s resource=%s/%s",
|
|
100
|
+
event.attempt,
|
|
101
|
+
event.event_type,
|
|
102
|
+
event.tenant_id,
|
|
103
|
+
event.resource_type,
|
|
104
|
+
event.resource_id,
|
|
105
|
+
)
|
|
106
|
+
if self._alert:
|
|
107
|
+
try:
|
|
108
|
+
await self._alert(event)
|
|
109
|
+
except Exception as alert_exc:
|
|
110
|
+
logger.error("Alert function failed: %s", alert_exc)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
await asyncio.sleep(self.RETRY_DELAY_S)
|
|
114
|
+
try:
|
|
115
|
+
await writer(event)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
self._record_failure(event, exc, step=f"retry_{event.attempt}")
|
|
118
|
+
event.attempt += 1
|
|
119
|
+
try:
|
|
120
|
+
self._retry_queue.put_nowait(event)
|
|
121
|
+
except asyncio.QueueFull:
|
|
122
|
+
logger.critical(
|
|
123
|
+
"compliance.audit_failure retry queue full on re-enqueue — event lost: "
|
|
124
|
+
"event_type=%s tenant_id=%s",
|
|
125
|
+
event.event_type, event.tenant_id,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _record_failure(self, event: AuditEvent, exc: Exception, step: str) -> None:
|
|
129
|
+
logger.error(
|
|
130
|
+
"compliance.audit_failure step=%s event_type=%s tenant_id=%s resource=%s/%s error=%s",
|
|
131
|
+
step, event.event_type, event.tenant_id,
|
|
132
|
+
event.resource_type, event.resource_id, exc,
|
|
133
|
+
)
|
|
134
|
+
if self._metrics:
|
|
135
|
+
try:
|
|
136
|
+
self._metrics(
|
|
137
|
+
"compliance.audit_failure",
|
|
138
|
+
{"event_type": event.event_type, "tenant_id": event.tenant_id, "step": step},
|
|
139
|
+
)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
polydb.services.security_service
|
|
3
|
+
==================================
|
|
4
|
+
Field-level encryption for sensitive model fields.
|
|
5
|
+
|
|
6
|
+
Fix M15: Missing POLYDB_ENCRYPTION_KEY previously caused encrypted fields to
|
|
7
|
+
be stored in plaintext with only a warning. Now raises EncryptionConfigError
|
|
8
|
+
at startup if any registered model has encrypted=true fields and the key is
|
|
9
|
+
not set.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("polydb.services.security")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EncryptionConfigError(RuntimeError):
|
|
23
|
+
"""Raised on startup when encryption is required but not configured."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SecurityService:
|
|
27
|
+
"""Field-level encryption.
|
|
28
|
+
|
|
29
|
+
Raises EncryptionConfigError at startup if any registered model has
|
|
30
|
+
encrypted=true fields and POLYDB_ENCRYPTION_KEY is not set.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
ENV_KEY = "POLYDB_ENCRYPTION_KEY"
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
encryption_key: Optional[bytes] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._key: Optional[bytes] = encryption_key or self._read_env_key()
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
# Startup validation
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def validate_startup(self, registered_models: List[Dict[str, Any]]) -> None:
|
|
47
|
+
"""Call during application startup after models are registered.
|
|
48
|
+
|
|
49
|
+
Raises EncryptionConfigError if any model declares encrypted fields
|
|
50
|
+
and the encryption key is absent. Fail-fast is intentional: running
|
|
51
|
+
without encryption when it is configured is a silent data-security
|
|
52
|
+
violation.
|
|
53
|
+
"""
|
|
54
|
+
if self._key:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
encrypted_models: List[str] = []
|
|
58
|
+
for m in registered_models:
|
|
59
|
+
fields = m.get("fields") or {}
|
|
60
|
+
if isinstance(fields, dict):
|
|
61
|
+
for _fname, fdef in fields.items():
|
|
62
|
+
if isinstance(fdef, dict) and fdef.get("encrypted"):
|
|
63
|
+
encrypted_models.append(m.get("name", "<unknown>"))
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
if encrypted_models:
|
|
67
|
+
raise EncryptionConfigError(
|
|
68
|
+
f"{self.ENV_KEY} is not set but the following models have "
|
|
69
|
+
f"encrypted=true fields: {', '.join(encrypted_models)}. "
|
|
70
|
+
f"Set {self.ENV_KEY} to a base64url-encoded 32-byte Fernet key "
|
|
71
|
+
f"or remove the encrypted=true flag from those model fields."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Encrypt / Decrypt
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def encrypt(self, value: str) -> str:
|
|
79
|
+
"""Encrypt *value*. Returns Fernet token (URL-safe base64 string)."""
|
|
80
|
+
if not self._key:
|
|
81
|
+
raise EncryptionConfigError(
|
|
82
|
+
f"Cannot encrypt: {self.ENV_KEY} is not set."
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
from cryptography.fernet import Fernet
|
|
86
|
+
return Fernet(self._key).encrypt(value.encode("utf-8")).decode("ascii")
|
|
87
|
+
except ImportError:
|
|
88
|
+
raise EncryptionConfigError(
|
|
89
|
+
"cryptography package is required for field encryption. "
|
|
90
|
+
"Install it: pip install cryptography"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def decrypt(self, value: str) -> str:
|
|
94
|
+
"""Decrypt a Fernet token produced by :meth:`encrypt`."""
|
|
95
|
+
if not self._key:
|
|
96
|
+
raise EncryptionConfigError(
|
|
97
|
+
f"Cannot decrypt: {self.ENV_KEY} is not set."
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
from cryptography.fernet import Fernet
|
|
101
|
+
return Fernet(self._key).decrypt(value.encode("ascii")).decode("utf-8")
|
|
102
|
+
except ImportError:
|
|
103
|
+
raise EncryptionConfigError(
|
|
104
|
+
"cryptography package is required for field encryption."
|
|
105
|
+
)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise ValueError(f"Decryption failed: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Internal
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def _read_env_key(cls) -> Optional[bytes]:
|
|
115
|
+
raw = os.environ.get(cls.ENV_KEY, "").strip()
|
|
116
|
+
if not raw:
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
# Fernet requires a 32-byte URL-safe base64 key (44 chars with padding)
|
|
120
|
+
key_bytes = raw.encode("ascii")
|
|
121
|
+
decoded = base64.urlsafe_b64decode(key_bytes + b"=" * (-len(raw) % 4))
|
|
122
|
+
if len(decoded) != 32:
|
|
123
|
+
logger.error(
|
|
124
|
+
"%s decoded to %d bytes; Fernet requires exactly 32 bytes. "
|
|
125
|
+
"Generate a valid key with: python -c \""
|
|
126
|
+
"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"",
|
|
127
|
+
cls.ENV_KEY, len(decoded),
|
|
128
|
+
)
|
|
129
|
+
return None
|
|
130
|
+
return key_bytes
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
logger.error("Failed to decode %s: %s", cls.ENV_KEY, exc)
|
|
133
|
+
return None
|
{altcodepro_polydb_python-2.3.20.dist-info → altcodepro_polydb_python-2.3.22.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|