altcodepro-polydb-python 2.3.20__py3-none-any.whl → 2.3.21__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.
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,3 @@
1
+ from .logging import configure_logging, set_polydb_log_context
2
+
3
+ __all__ = ["configure_logging", "set_polydb_log_context"]
@@ -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,9 @@
1
+ from .compliance_service import ComplianceService, AuditEvent
2
+ from .security_service import SecurityService, EncryptionConfigError
3
+
4
+ __all__ = [
5
+ "ComplianceService",
6
+ "AuditEvent",
7
+ "SecurityService",
8
+ "EncryptionConfigError",
9
+ ]
@@ -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