pgdispatch 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.
Files changed (54) hide show
  1. pgdispatch/__init__.py +69 -0
  2. pgdispatch/__main__.py +5 -0
  3. pgdispatch/_version.py +1 -0
  4. pgdispatch/backends/__init__.py +6 -0
  5. pgdispatch/backends/asyncpg.py +103 -0
  6. pgdispatch/backends/base.py +142 -0
  7. pgdispatch/cli.py +420 -0
  8. pgdispatch/client.py +490 -0
  9. pgdispatch/config.py +223 -0
  10. pgdispatch/core/__init__.py +1 -0
  11. pgdispatch/core/circuit.py +94 -0
  12. pgdispatch/core/common.py +87 -0
  13. pgdispatch/core/deliveries.py +216 -0
  14. pgdispatch/core/endpoints.py +338 -0
  15. pgdispatch/core/events.py +65 -0
  16. pgdispatch/core/maintenance.py +121 -0
  17. pgdispatch/core/matching.py +76 -0
  18. pgdispatch/core/payload.py +108 -0
  19. pgdispatch/core/publisher.py +232 -0
  20. pgdispatch/core/retry.py +90 -0
  21. pgdispatch/core/secrets.py +138 -0
  22. pgdispatch/core/signing.py +141 -0
  23. pgdispatch/django/__init__.py +37 -0
  24. pgdispatch/django/apps.py +10 -0
  25. pgdispatch/django/client.py +402 -0
  26. pgdispatch/django/management/__init__.py +0 -0
  27. pgdispatch/django/management/commands/__init__.py +0 -0
  28. pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
  29. pgdispatch/django/migrations/0001_initial.py +30 -0
  30. pgdispatch/django/migrations/__init__.py +0 -0
  31. pgdispatch/errors.py +43 -0
  32. pgdispatch/fastapi/__init__.py +23 -0
  33. pgdispatch/fastapi/router.py +243 -0
  34. pgdispatch/hooks.py +80 -0
  35. pgdispatch/metrics.py +80 -0
  36. pgdispatch/migrations/0001_initial.sql +104 -0
  37. pgdispatch/migrations/__init__.py +93 -0
  38. pgdispatch/models.py +208 -0
  39. pgdispatch/py.typed +0 -0
  40. pgdispatch/security/__init__.py +1 -0
  41. pgdispatch/security/ssrf.py +141 -0
  42. pgdispatch/sqlalchemy.py +130 -0
  43. pgdispatch/worker/__init__.py +5 -0
  44. pgdispatch/worker/fetcher.py +183 -0
  45. pgdispatch/worker/ratelimit.py +47 -0
  46. pgdispatch/worker/recorder.py +435 -0
  47. pgdispatch/worker/runner.py +36 -0
  48. pgdispatch/worker/sender.py +160 -0
  49. pgdispatch/worker/worker.py +557 -0
  50. pgdispatch-0.1.0.dist-info/METADATA +329 -0
  51. pgdispatch-0.1.0.dist-info/RECORD +54 -0
  52. pgdispatch-0.1.0.dist-info/WHEEL +4 -0
  53. pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
  54. pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,108 @@
1
+ """Event payload serialization and the body format sent to receivers.
2
+
3
+ Receivers get the envelope recommended by Standard Webhooks::
4
+
5
+ {"type": "payment.paid", "timestamp": "2026-11-01T10:15:00.123Z", "data": {...}}
6
+
7
+ The body is produced once per attempt and the exact same bytes are signed and
8
+ sent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ import enum
15
+ import json
16
+ import os
17
+ import time
18
+ import uuid
19
+ from datetime import date, datetime, timezone
20
+ from decimal import Decimal
21
+ from typing import Any
22
+
23
+ from pgdispatch.errors import PayloadTooLargeError, ValidationError
24
+
25
+ __all__ = ["build_body", "dumps", "serialize_payload", "uuid7"]
26
+
27
+
28
+ def dumps(value: Any) -> str:
29
+ """Serialize to compact JSON, understanding common Python types.
30
+
31
+ Supported beyond plain JSON: ``datetime``/``date`` (ISO 8601), ``UUID``,
32
+ ``Decimal`` (as a string, to keep precision), ``Enum``, sets and tuples,
33
+ dataclasses and Pydantic models.
34
+ """
35
+ return json.dumps(
36
+ value,
37
+ default=_default,
38
+ ensure_ascii=False,
39
+ allow_nan=False,
40
+ separators=(",", ":"),
41
+ )
42
+
43
+
44
+ def serialize_payload(payload: Any, *, max_bytes: int) -> str:
45
+ """Serialize an event payload, enforcing the size limit and PostgreSQL's ``jsonb`` rules."""
46
+ try:
47
+ text = dumps(payload)
48
+ except (TypeError, ValueError) as exc:
49
+ msg = f"payload is not JSON-serializable: {exc}"
50
+ raise ValidationError(msg) from exc
51
+ if "\\u0000" in text:
52
+ msg = "payload must not contain NUL characters (\\u0000); PostgreSQL jsonb rejects them"
53
+ raise ValidationError(msg)
54
+ size = len(text.encode())
55
+ if size > max_bytes:
56
+ msg = f"payload is {size} bytes, the limit is {max_bytes} (max_payload_bytes)"
57
+ raise PayloadTooLargeError(msg)
58
+ return text
59
+
60
+
61
+ def build_body(event_type: str, created_at: datetime, payload_json: str) -> bytes:
62
+ """Wrap a stored payload in the Standard Webhooks envelope.
63
+
64
+ ``payload_json`` is the payload exactly as PostgreSQL returned it, so it is
65
+ embedded verbatim instead of being decoded and encoded again.
66
+ """
67
+ timestamp = created_at.astimezone(timezone.utc).isoformat(timespec="milliseconds")
68
+ timestamp = timestamp.replace("+00:00", "Z")
69
+ head = dumps({"type": event_type, "timestamp": timestamp})
70
+ return f'{head[:-1]},"data":{payload_json}}}'.encode()
71
+
72
+
73
+ def uuid7() -> uuid.UUID:
74
+ """Generate a time-ordered UUID (RFC 9562, version 7).
75
+
76
+ Time ordering keeps the events primary-key index compact and lets ids be
77
+ sorted chronologically.
78
+ """
79
+ timestamp_ms = time.time_ns() // 1_000_000
80
+ rand = int.from_bytes(os.urandom(10), "big")
81
+ value = (timestamp_ms & 0xFFFF_FFFF_FFFF) << 80
82
+ value |= 0x7 << 76 # version
83
+ value |= (rand >> 68) << 64 # 12 random bits
84
+ value |= 0b10 << 62 # variant
85
+ value |= rand & 0x3FFF_FFFF_FFFF_FFFF # 62 random bits
86
+ return uuid.UUID(int=value)
87
+
88
+
89
+ def _default(value: Any) -> Any: # noqa: PLR0911 - one return per supported type
90
+ if isinstance(value, datetime):
91
+ return value.isoformat()
92
+ if isinstance(value, date):
93
+ return value.isoformat()
94
+ if isinstance(value, uuid.UUID):
95
+ return str(value)
96
+ if isinstance(value, Decimal):
97
+ return str(value)
98
+ if isinstance(value, enum.Enum):
99
+ return value.value
100
+ if isinstance(value, (set, frozenset, tuple)):
101
+ return list(value)
102
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
103
+ return dataclasses.asdict(value)
104
+ model_dump = getattr(value, "model_dump", None)
105
+ if callable(model_dump):
106
+ return model_dump(mode="json")
107
+ msg = f"Object of type {type(value).__name__} is not JSON serializable"
108
+ raise TypeError(msg)
@@ -0,0 +1,232 @@
1
+ """Publishing events: the transactional outbox.
2
+
3
+ :func:`publish` runs inside the caller's transaction and, in one statement,
4
+
5
+ 1. inserts the event (skipping it if the idempotency key is already known),
6
+ 2. fans it out into one ``deliveries`` row per subscribed endpoint, and
7
+ 3. queues a ``NOTIFY`` for the workers.
8
+
9
+ PostgreSQL delivers notifications only when the transaction commits, so a
10
+ rolled-back transaction leaves no event, no deliveries and no wake-up.
11
+ Subscriptions are resolved at publish time: an endpoint created later does not
12
+ receive earlier events (use replay for that).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import uuid
18
+ from datetime import timedelta
19
+ from typing import Any
20
+
21
+ from pgdispatch.backends.base import Operation, Query
22
+ from pgdispatch.core.common import (
23
+ OwnerFilter,
24
+ first_or_not_found,
25
+ owner_condition,
26
+ validate_key,
27
+ )
28
+ from pgdispatch.core.matching import candidate_patterns, validate_event_type
29
+ from pgdispatch.core.payload import serialize_payload, uuid7
30
+ from pgdispatch.errors import ConflictError, NotFoundError
31
+ from pgdispatch.models import PublishResult, to_uuid
32
+
33
+ __all__ = ["TEST_EVENT_TYPE", "publish", "replay", "send_test"]
34
+
35
+ TEST_EVENT_TYPE = "webhook.test"
36
+
37
+ _FANOUT_TARGETS = """
38
+ FROM {schema}.endpoints AS e
39
+ WHERE e.is_enabled
40
+ AND {owner_match}
41
+ AND e.event_types && CAST(:patterns AS text[])
42
+ """
43
+
44
+ _PUBLISH = """
45
+ WITH new_event AS (
46
+ INSERT INTO {schema}.events (id, event_type, owner_id, payload, idempotency_key)
47
+ VALUES (CAST(:event_id AS uuid), CAST(:event_type AS text), CAST(:event_owner AS text),
48
+ CAST(:payload AS jsonb), CAST(:idempotency_key AS text))
49
+ ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
50
+ RETURNING id
51
+ ), fanout AS (
52
+ INSERT INTO {schema}.deliveries (event_id, endpoint_id, ordering_key, next_attempt_at)
53
+ SELECT new_event.id, e.id, CAST(:ordering_key AS text),
54
+ now() + CAST(:initial_delay AS double precision) * interval '1 second'
55
+ FROM new_event, ({targets}) AS e
56
+ RETURNING 1
57
+ ), wake AS (
58
+ SELECT pg_notify(CAST(:channel AS text), '') FROM fanout LIMIT 1
59
+ )
60
+ SELECT (SELECT count(*) FROM new_event) AS inserted,
61
+ (SELECT count(*) FROM fanout) AS deliveries,
62
+ (SELECT count(*) FROM wake) AS notified
63
+ """
64
+
65
+
66
+ def publish(
67
+ *,
68
+ event_type: str,
69
+ payload: Any,
70
+ owner_id: str | None,
71
+ ordering_key: str | None,
72
+ idempotency_key: str | None,
73
+ channel: str,
74
+ max_payload_bytes: int,
75
+ initial_delay: timedelta = timedelta(0),
76
+ ) -> Operation[PublishResult]:
77
+ validate_event_type(event_type)
78
+ params: dict[str, Any] = {
79
+ "event_id": str(uuid7()),
80
+ "event_type": event_type,
81
+ "event_owner": validate_key(owner_id, "owner_id") if owner_id is not None else None,
82
+ "payload": serialize_payload(payload, max_bytes=max_payload_bytes),
83
+ "ordering_key": validate_key(ordering_key, "ordering_key") if ordering_key else None,
84
+ "idempotency_key": (
85
+ validate_key(idempotency_key, "idempotency_key") if idempotency_key else None
86
+ ),
87
+ "patterns": candidate_patterns(event_type),
88
+ "channel": channel,
89
+ "initial_delay": initial_delay.total_seconds(),
90
+ }
91
+ sql = _PUBLISH.replace("{targets}", "SELECT e.id " + _fanout_targets(owner_id, params))
92
+ rows = yield Query(sql, params)
93
+ row = rows[0]
94
+ if row["inserted"]:
95
+ return PublishResult(event_id=uuid.UUID(params["event_id"]), deliveries=row["deliveries"])
96
+
97
+ # The idempotency key exists. In READ COMMITTED this statement sees rows
98
+ # committed after the previous one started, so a concurrent duplicate is found.
99
+ rows = yield Query(
100
+ "SELECT id FROM {schema}.events WHERE idempotency_key = CAST(:key AS text)",
101
+ {"key": idempotency_key},
102
+ )
103
+ if not rows: # pragma: no cover - only under REPEATABLE READ with a concurrent publisher
104
+ msg = f"an event with idempotency key {idempotency_key!r} is being published concurrently"
105
+ raise ConflictError(msg)
106
+ return PublishResult(event_id=to_uuid(rows[0]["id"]), deliveries=0, duplicate=True)
107
+
108
+
109
+ def replay(
110
+ event_id: uuid.UUID,
111
+ *,
112
+ endpoint_id: uuid.UUID | None,
113
+ owner_id: OwnerFilter,
114
+ channel: str,
115
+ ) -> Operation[list[int]]:
116
+ """Deliver an existing event again; return the ids of the new deliveries.
117
+
118
+ Without ``endpoint_id`` the event goes to every enabled endpoint that is
119
+ subscribed *now*, which is how you backfill a newly created endpoint.
120
+ Replays bypass ``ordering_key`` so they never wait behind live traffic.
121
+ """
122
+ params: dict[str, Any] = {"event_id": str(event_id), "channel": channel}
123
+ owner_sql = owner_condition("ev.owner_id", owner_id, params)
124
+ rows = yield Query(
125
+ f"""
126
+ SELECT ev.id, ev.event_type, ev.owner_id FROM {{schema}}.events AS ev
127
+ WHERE ev.id = CAST(:event_id AS uuid) AND {owner_sql}
128
+ """,
129
+ params,
130
+ )
131
+ event = first_or_not_found(rows, "event", event_id)
132
+
133
+ insert_params: dict[str, Any] = {"event_id": str(event_id), "channel": channel}
134
+ if endpoint_id is not None:
135
+ # Only endpoints of the event's owner may receive it: never leak across tenants.
136
+ insert_params["endpoint_id"] = str(endpoint_id)
137
+ same_owner = _owner_match(event["owner_id"], insert_params, column="e.owner_id")
138
+ targets = f"""
139
+ SELECT e.id FROM {{schema}}.endpoints AS e
140
+ WHERE e.id = CAST(:endpoint_id AS uuid) AND {same_owner}
141
+ """
142
+ else:
143
+ insert_params["patterns"] = candidate_patterns(event["event_type"])
144
+ targets = "SELECT e.id " + _fanout_targets(event["owner_id"], insert_params)
145
+
146
+ rows = yield Query(
147
+ f"""
148
+ WITH created AS (
149
+ INSERT INTO {{schema}}.deliveries (event_id, endpoint_id)
150
+ SELECT CAST(:event_id AS uuid), targets.id FROM ({targets}) AS targets
151
+ RETURNING id
152
+ ), wake AS (
153
+ SELECT pg_notify(CAST(:channel AS text), '') FROM created LIMIT 1
154
+ )
155
+ SELECT id, (SELECT count(*) FROM wake) AS notified FROM created ORDER BY id
156
+ """,
157
+ insert_params,
158
+ )
159
+ if endpoint_id is not None and not rows:
160
+ msg = f"endpoint {endpoint_id} not found for the owner of event {event_id}"
161
+ raise NotFoundError(msg)
162
+ return [row["id"] for row in rows]
163
+
164
+
165
+ def send_test(
166
+ endpoint_id: uuid.UUID,
167
+ *,
168
+ owner_id: OwnerFilter,
169
+ channel: str,
170
+ ) -> Operation[PublishResult]:
171
+ """Send a ``webhook.test`` event to one endpoint, regardless of its subscriptions."""
172
+ params: dict[str, Any] = {"endpoint_id": str(endpoint_id)}
173
+ owner_sql = owner_condition("e.owner_id", owner_id, params)
174
+ rows = yield Query(
175
+ f"""
176
+ SELECT e.id, e.owner_id, e.is_enabled FROM {{schema}}.endpoints AS e
177
+ WHERE e.id = CAST(:endpoint_id AS uuid) AND {owner_sql}
178
+ FOR SHARE
179
+ """,
180
+ params,
181
+ )
182
+ endpoint = first_or_not_found(rows, "endpoint", endpoint_id)
183
+ if not endpoint["is_enabled"]:
184
+ msg = f"endpoint {endpoint_id} is disabled; enable it before sending a test event"
185
+ raise ConflictError(msg)
186
+
187
+ event_id = uuid7()
188
+ payload = {
189
+ "message": "This is a test event sent by pgdispatch.",
190
+ "endpoint_id": str(endpoint_id),
191
+ }
192
+ yield Query(
193
+ """
194
+ WITH new_event AS (
195
+ INSERT INTO {schema}.events (id, event_type, owner_id, payload)
196
+ VALUES (CAST(:event_id AS uuid), CAST(:event_type AS text),
197
+ CAST(:owner_id AS text), CAST(:payload AS jsonb))
198
+ RETURNING id
199
+ ), delivery AS (
200
+ INSERT INTO {schema}.deliveries (event_id, endpoint_id)
201
+ SELECT new_event.id, CAST(:endpoint_id AS uuid) FROM new_event
202
+ RETURNING 1
203
+ ), wake AS (
204
+ SELECT pg_notify(CAST(:channel AS text), '') FROM delivery LIMIT 1
205
+ )
206
+ SELECT (SELECT count(*) FROM delivery) AS deliveries,
207
+ (SELECT count(*) FROM wake) AS notified
208
+ """,
209
+ {
210
+ "event_id": str(event_id),
211
+ "event_type": TEST_EVENT_TYPE,
212
+ "owner_id": endpoint["owner_id"],
213
+ "payload": serialize_payload(payload, max_bytes=1024),
214
+ "endpoint_id": str(endpoint_id),
215
+ "channel": channel,
216
+ },
217
+ )
218
+ return PublishResult(event_id=event_id, deliveries=1)
219
+
220
+
221
+ def _fanout_targets(owner_id: str | None, params: dict[str, Any]) -> str:
222
+ owner_match = _owner_match(owner_id, params, column="e.owner_id")
223
+ return _FANOUT_TARGETS.replace("{owner_match}", owner_match)
224
+
225
+
226
+ def _owner_match(owner_id: str | None, params: dict[str, Any], *, column: str) -> str:
227
+ # Events are only ever delivered to endpoints of the same owner (or, for
228
+ # events without an owner, to endpoints without an owner).
229
+ if owner_id is None:
230
+ return f"{column} IS NULL"
231
+ params["target_owner"] = owner_id
232
+ return f"{column} = CAST(:target_owner AS text)"
@@ -0,0 +1,90 @@
1
+ """Retry schedule, jitter and ``Retry-After`` handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from datetime import datetime, timedelta, timezone
7
+ from email.utils import parsedate_to_datetime
8
+
9
+ __all__ = [
10
+ "DEFAULT_RETRY_SCHEDULE",
11
+ "exponential_backoff",
12
+ "next_retry_delay",
13
+ "parse_retry_after",
14
+ ]
15
+
16
+ #: Eight attempts over roughly 27 hours, the schedule popularised by Stripe and Svix.
17
+ #: Element *n* is the delay before attempt *n + 1*; the first attempt is immediate.
18
+ DEFAULT_RETRY_SCHEDULE: tuple[timedelta, ...] = (
19
+ timedelta(0),
20
+ timedelta(seconds=5),
21
+ timedelta(minutes=5),
22
+ timedelta(minutes=30),
23
+ timedelta(hours=2),
24
+ timedelta(hours=5),
25
+ timedelta(hours=10),
26
+ timedelta(hours=10),
27
+ )
28
+
29
+
30
+ def exponential_backoff(
31
+ attempts: int,
32
+ *,
33
+ initial: timedelta = timedelta(seconds=5),
34
+ factor: float = 2.0,
35
+ max_delay: timedelta = timedelta(hours=10),
36
+ ) -> tuple[timedelta, ...]:
37
+ """Build a retry schedule whose delays grow geometrically.
38
+
39
+ ``exponential_backoff(4, initial=timedelta(seconds=10))`` yields delays of
40
+ 0 s, 10 s, 20 s and 40 s. The result can be passed as ``retry_schedule``.
41
+ """
42
+ if attempts < 1:
43
+ msg = "attempts must be at least 1"
44
+ raise ValueError(msg)
45
+ if factor < 1:
46
+ msg = "factor must be at least 1"
47
+ raise ValueError(msg)
48
+ delays = [timedelta(0)]
49
+ for retry in range(attempts - 1):
50
+ delays.append(min(initial * factor**retry, max_delay))
51
+ return tuple(delays)
52
+
53
+
54
+ def next_retry_delay(
55
+ schedule: tuple[timedelta, ...],
56
+ attempts_made: int,
57
+ *,
58
+ jitter: float = 0.0,
59
+ rng: random.Random | None = None,
60
+ ) -> timedelta | None:
61
+ """Return the delay before the next attempt, or ``None`` when attempts are exhausted.
62
+
63
+ ``attempts_made`` counts the attempts already performed in the current retry
64
+ round. A random spread of ``±jitter`` is applied so that retries for many
65
+ deliveries that failed together do not hit the receiver at the same instant.
66
+ """
67
+ if attempts_made >= len(schedule):
68
+ return None
69
+ delay = schedule[attempts_made]
70
+ if jitter and delay:
71
+ spread = (rng or random).uniform(1 - jitter, 1 + jitter)
72
+ delay = delay * spread
73
+ return delay
74
+
75
+
76
+ def parse_retry_after(value: str | None, *, now: datetime | None = None) -> timedelta | None:
77
+ """Parse an HTTP ``Retry-After`` header (delta-seconds or HTTP-date)."""
78
+ if not value:
79
+ return None
80
+ value = value.strip()
81
+ if value.isdigit():
82
+ return timedelta(seconds=int(value))
83
+ try:
84
+ moment = parsedate_to_datetime(value)
85
+ except (TypeError, ValueError, IndexError):
86
+ return None
87
+ if moment.tzinfo is None:
88
+ moment = moment.replace(tzinfo=timezone.utc)
89
+ delay = moment - (now or datetime.now(timezone.utc))
90
+ return max(delay, timedelta(0))
@@ -0,0 +1,138 @@
1
+ """Storage of endpoint signing secrets, including rotation and optional encryption.
2
+
3
+ An endpoint keeps a list of secrets in a ``jsonb`` column::
4
+
5
+ [{"key": "whsec_...", "encrypted": false,
6
+ "created_at": "2026-11-01T10:00:00+00:00", "expires_at": null}]
7
+
8
+ The newest secret comes first. When a secret is rotated the previous ones get
9
+ an ``expires_at`` timestamp; until then requests are signed with every active
10
+ secret, so receivers can switch keys without dropping a single webhook.
11
+
12
+ Secrets cannot be hashed because the worker needs them to sign requests. They
13
+ can, however, be encrypted at rest with any object implementing
14
+ :class:`SecretCipher` — ``cryptography.fernet.Fernet`` and ``MultiFernet`` fit.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timedelta
23
+ from typing import Any, Protocol
24
+
25
+ from pgdispatch.errors import ConfigurationError
26
+
27
+ __all__ = ["SecretCipher", "SecretEntry", "SecretStore", "fernet_cipher"]
28
+
29
+
30
+ class SecretCipher(Protocol):
31
+ """Symmetric encryption used for secrets at rest (``Fernet`` compatible)."""
32
+
33
+ def encrypt(self, data: bytes) -> bytes: ...
34
+
35
+ def decrypt(self, token: bytes) -> bytes: ...
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class SecretEntry:
40
+ key: str
41
+ encrypted: bool
42
+ created_at: datetime
43
+ expires_at: datetime | None
44
+
45
+ def is_active(self, now: datetime) -> bool:
46
+ return self.expires_at is None or self.expires_at > now
47
+
48
+ def to_json(self) -> dict[str, Any]:
49
+ return {
50
+ "key": self.key,
51
+ "encrypted": self.encrypted,
52
+ "created_at": self.created_at.isoformat(),
53
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
54
+ }
55
+
56
+ @classmethod
57
+ def from_json(cls, data: dict[str, Any]) -> SecretEntry:
58
+ expires_at = data.get("expires_at")
59
+ return cls(
60
+ key=data["key"],
61
+ encrypted=bool(data.get("encrypted", False)),
62
+ created_at=datetime.fromisoformat(data["created_at"]),
63
+ expires_at=datetime.fromisoformat(expires_at) if expires_at else None,
64
+ )
65
+
66
+
67
+ class SecretStore:
68
+ """Seals, opens and rotates the secrets of an endpoint."""
69
+
70
+ def __init__(self, cipher: SecretCipher | None = None) -> None:
71
+ self._cipher = cipher
72
+
73
+ def seal(self, secret: str, *, now: datetime) -> SecretEntry:
74
+ if self._cipher is None:
75
+ return SecretEntry(key=secret, encrypted=False, created_at=now, expires_at=None)
76
+ token = self._cipher.encrypt(secret.encode()).decode("ascii")
77
+ return SecretEntry(key=token, encrypted=True, created_at=now, expires_at=None)
78
+
79
+ def open(self, entry: SecretEntry) -> str:
80
+ if not entry.encrypted:
81
+ return entry.key
82
+ if self._cipher is None:
83
+ msg = (
84
+ "endpoint secrets are encrypted but no decryption key is configured; "
85
+ "set secret_encryption_keys (PGDISPATCH_SECRET_ENCRYPTION_KEYS)"
86
+ )
87
+ raise ConfigurationError(msg)
88
+ return self._cipher.decrypt(entry.key.encode("ascii")).decode()
89
+
90
+ def active_secrets(self, raw: str | Sequence[dict[str, Any]], *, now: datetime) -> list[str]:
91
+ """Plaintext secrets that must sign a request at ``now``, newest first."""
92
+ return [self.open(entry) for entry in self.load(raw) if entry.is_active(now)]
93
+
94
+ @staticmethod
95
+ def load(raw: str | Sequence[dict[str, Any]]) -> list[SecretEntry]:
96
+ items = json.loads(raw) if isinstance(raw, str) else raw
97
+ return [SecretEntry.from_json(item) for item in items]
98
+
99
+ @staticmethod
100
+ def dump(entries: Sequence[SecretEntry]) -> str:
101
+ return json.dumps([entry.to_json() for entry in entries])
102
+
103
+ def rotate(
104
+ self,
105
+ entries: Sequence[SecretEntry],
106
+ new_secret: str,
107
+ *,
108
+ now: datetime,
109
+ grace_period: timedelta,
110
+ ) -> list[SecretEntry]:
111
+ """Put ``new_secret`` first; currently active secrets expire after ``grace_period``."""
112
+ deadline = now + grace_period
113
+ kept = [
114
+ SecretEntry(
115
+ key=entry.key,
116
+ encrypted=entry.encrypted,
117
+ created_at=entry.created_at,
118
+ expires_at=min(entry.expires_at, deadline) if entry.expires_at else deadline,
119
+ )
120
+ for entry in entries
121
+ if entry.is_active(now) and grace_period > timedelta(0)
122
+ ]
123
+ return [self.seal(new_secret, now=now), *kept]
124
+
125
+
126
+ def fernet_cipher(keys: Sequence[str]) -> SecretCipher:
127
+ """Build a ``MultiFernet`` cipher; the first key encrypts, all keys decrypt."""
128
+ try:
129
+ from cryptography.fernet import Fernet, MultiFernet
130
+ except ImportError as exc: # pragma: no cover - depends on the environment
131
+ msg = (
132
+ 'secret encryption requires the encryption extra: pip install "pgdispatch[encryption]"'
133
+ )
134
+ raise ConfigurationError(msg) from exc
135
+ if not keys:
136
+ msg = "at least one Fernet key is required"
137
+ raise ConfigurationError(msg)
138
+ return MultiFernet([Fernet(key) for key in keys])