terp-cap-webhooks 0.1.0__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.
- terp_cap_webhooks-0.1.0/.gitignore +47 -0
- terp_cap_webhooks-0.1.0/PKG-INFO +9 -0
- terp_cap_webhooks-0.1.0/escape-hatch-budget.json +6 -0
- terp_cap_webhooks-0.1.0/pyproject.toml +35 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/__init__.py +104 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/delivery.py +315 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/migrations/versions/717754ab63f6_create_webhooks_tables.py +80 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/migrations/versions/8f1c4a2d9b3e_widen_webhook_secret_for_sealing.py +48 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/models.py +95 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/py.typed +0 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/router.py +96 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/schemas.py +76 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/sealing.py +86 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/service.py +91 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/ssrf.py +193 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/store.py +64 -0
- terp_cap_webhooks-0.1.0/src/terp/capabilities/webhooks/triggers.py +54 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cap-webhooks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terp webhooks capability — reliable, signed, SSRF-guarded outbound webhooks on the jobs/outbox seam.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: cryptography>=48.0.1
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: terp-core==0.1.0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-webhooks"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp webhooks capability — reliable, signed, SSRF-guarded outbound webhooks on the jobs/outbox seam."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
# The outbound HTTP client lives ONLY in this capability (the delivery job handler);
|
|
14
|
+
# an app module never imports it. Background delivery + retry ride the jobs/outbox seam.
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
# Seals the subscription signing secret at rest (ADR 0076) — Fernet keyed from
|
|
17
|
+
# SECRET_KEY with a webhooks-specific HKDF label, mirroring terp.core.secrets.
|
|
18
|
+
"cryptography>=48.0.1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# Self-registering: the kernel discovers this ModuleSpec via the entry point, mounting the
|
|
22
|
+
# admin webhooks router at `/api/v1/webhooks` without any composition-root edit.
|
|
23
|
+
[project.entry-points."terp.capabilities"]
|
|
24
|
+
webhooks = "terp.capabilities.webhooks:module"
|
|
25
|
+
|
|
26
|
+
# Owns the `webhook_subscription` + append-only `webhook_delivery` tables, so it ships an
|
|
27
|
+
# independent, linear Alembic history (its own `alembic_version_webhooks` table). `terp
|
|
28
|
+
# migrate` discovers this via the `terp.migrations` group (ADR 0027).
|
|
29
|
+
[project.entry-points."terp.migrations"]
|
|
30
|
+
webhooks = "terp.capabilities.webhooks"
|
|
31
|
+
|
|
32
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.webhooks`.
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
sources = ["src"]
|
|
35
|
+
only-include = ["src/terp/capabilities/webhooks"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""terp.capabilities.webhooks — reliable, signed, SSRF-guarded outbound webhooks.
|
|
2
|
+
|
|
3
|
+
Built **only** on the shipped ports: the jobs seam (:func:`terp.core.enqueue` + a typed
|
|
4
|
+
:class:`~terp.core.JobDefinition`) and the durable outbox (for retry / dead-letter). It adds
|
|
5
|
+
no engine and changes no ``terp.core``.
|
|
6
|
+
|
|
7
|
+
* A consumer registers an owner-scoped :class:`WebhookSubscription` through the discovered,
|
|
8
|
+
admin-only router at ``/api/v1/webhooks``; the signing ``secret`` is supplied on create and
|
|
9
|
+
is never serialized back out.
|
|
10
|
+
* An app wires :func:`enqueue_webhook_deliveries` to a catalog event with the eventbus
|
|
11
|
+
``@subscribe`` decorator, so when the event fires the matching deliveries are enqueued **on
|
|
12
|
+
the producer's session** — atomically with the business write (no dual-write).
|
|
13
|
+
* The :data:`WEBHOOK_DELIVER` job (drained by the outbox worker, ``terp jobs worker``) signs
|
|
14
|
+
the payload (HMAC-SHA256), re-checks the target against the SSRF denylist, POSTs with a
|
|
15
|
+
strict timeout and no redirect following, records a :class:`WebhookDelivery`, and lets a
|
|
16
|
+
failure propagate so the outbox retries with backoff and dead-letters.
|
|
17
|
+
|
|
18
|
+
It depends only on ``terp-core`` and ``httpx`` — never a sibling capability or a broker
|
|
19
|
+
engine; the app composes the durable ``OutboxJobQueue`` at ``create_app``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from terp.capabilities.webhooks.delivery import (
|
|
25
|
+
WEBHOOK_DELIVER,
|
|
26
|
+
WebhookDeliveryError,
|
|
27
|
+
WebhookDeliveryPayload,
|
|
28
|
+
WebhookResponse,
|
|
29
|
+
WebhookSender,
|
|
30
|
+
active_webhook_sender,
|
|
31
|
+
deliver_webhook,
|
|
32
|
+
reset_webhook_sender,
|
|
33
|
+
set_webhook_sender,
|
|
34
|
+
)
|
|
35
|
+
from terp.capabilities.webhooks.models import (
|
|
36
|
+
OUTCOME_BLOCKED,
|
|
37
|
+
OUTCOME_DELIVERED,
|
|
38
|
+
OUTCOME_FAILED,
|
|
39
|
+
OUTCOME_SKIPPED,
|
|
40
|
+
WebhookDelivery,
|
|
41
|
+
WebhookSubscription,
|
|
42
|
+
)
|
|
43
|
+
from terp.capabilities.webhooks.router import module, router
|
|
44
|
+
from terp.capabilities.webhooks.sealing import (
|
|
45
|
+
WebhookSecretError,
|
|
46
|
+
is_sealed_secret,
|
|
47
|
+
seal_secret,
|
|
48
|
+
unseal_secret,
|
|
49
|
+
)
|
|
50
|
+
from terp.capabilities.webhooks.schemas import (
|
|
51
|
+
WebhookDeliveryRead,
|
|
52
|
+
WebhookSubscriptionCreate,
|
|
53
|
+
WebhookSubscriptionRead,
|
|
54
|
+
WebhookSubscriptionUpdate,
|
|
55
|
+
)
|
|
56
|
+
from terp.capabilities.webhooks.service import WebhookSubscriptionService, list_deliveries
|
|
57
|
+
from terp.capabilities.webhooks.ssrf import (
|
|
58
|
+
CLOUD_METADATA_ADDRESS,
|
|
59
|
+
PinnedTarget,
|
|
60
|
+
WebhookTargetError,
|
|
61
|
+
is_denied_address,
|
|
62
|
+
resolve_pinned_target,
|
|
63
|
+
validate_webhook_target,
|
|
64
|
+
)
|
|
65
|
+
from terp.capabilities.webhooks.store import record_delivery
|
|
66
|
+
from terp.capabilities.webhooks.triggers import enqueue_webhook_deliveries
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"CLOUD_METADATA_ADDRESS",
|
|
70
|
+
"OUTCOME_BLOCKED",
|
|
71
|
+
"OUTCOME_DELIVERED",
|
|
72
|
+
"OUTCOME_FAILED",
|
|
73
|
+
"OUTCOME_SKIPPED",
|
|
74
|
+
"WEBHOOK_DELIVER",
|
|
75
|
+
"PinnedTarget",
|
|
76
|
+
"WebhookDelivery",
|
|
77
|
+
"WebhookDeliveryError",
|
|
78
|
+
"WebhookDeliveryPayload",
|
|
79
|
+
"WebhookDeliveryRead",
|
|
80
|
+
"WebhookResponse",
|
|
81
|
+
"WebhookSecretError",
|
|
82
|
+
"WebhookSender",
|
|
83
|
+
"WebhookSubscription",
|
|
84
|
+
"WebhookSubscriptionCreate",
|
|
85
|
+
"WebhookSubscriptionRead",
|
|
86
|
+
"WebhookSubscriptionService",
|
|
87
|
+
"WebhookSubscriptionUpdate",
|
|
88
|
+
"WebhookTargetError",
|
|
89
|
+
"active_webhook_sender",
|
|
90
|
+
"deliver_webhook",
|
|
91
|
+
"enqueue_webhook_deliveries",
|
|
92
|
+
"is_denied_address",
|
|
93
|
+
"is_sealed_secret",
|
|
94
|
+
"list_deliveries",
|
|
95
|
+
"module",
|
|
96
|
+
"record_delivery",
|
|
97
|
+
"reset_webhook_sender",
|
|
98
|
+
"resolve_pinned_target",
|
|
99
|
+
"router",
|
|
100
|
+
"seal_secret",
|
|
101
|
+
"set_webhook_sender",
|
|
102
|
+
"unseal_secret",
|
|
103
|
+
"validate_webhook_target",
|
|
104
|
+
]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""The ``WEBHOOK_DELIVER`` job: sign + POST one delivery, record the attempt, retry on failure.
|
|
2
|
+
|
|
3
|
+
This is the worker half of the webhook seam (the jobs design's §6 / §7): the external HTTP
|
|
4
|
+
call lives **here**, in a job handler that runs **post-commit, on a worker**, drained from
|
|
5
|
+
the durable outbox — never in an ``_after_write`` hook (which would dual-write the business
|
|
6
|
+
row and a remote call). The trigger (an event subscriber) only *enqueues* this job, atomically
|
|
7
|
+
with the business write; this handler delivers it off-request, and a failure **propagates** so
|
|
8
|
+
the :class:`~terp.core.RetryPolicy` + outbox worker retry with exponential backoff and
|
|
9
|
+
dead-letter after the attempt budget is spent.
|
|
10
|
+
|
|
11
|
+
Security controls applied on every attempt:
|
|
12
|
+
|
|
13
|
+
* an **SSRF re-check** of the target immediately before the request (DNS-rebinding defense,
|
|
14
|
+
:mod:`terp.capabilities.webhooks.ssrf`);
|
|
15
|
+
* an **HMAC-SHA256** signature over the exact JSON body, keyed by the subscription's stored
|
|
16
|
+
``secret`` — which never leaves the server;
|
|
17
|
+
* a strict outbound **timeout**, a bounded outbound **payload size**, and **no redirect
|
|
18
|
+
following** (a 3xx is recorded as a failure, never chased to a possibly-disallowed host).
|
|
19
|
+
|
|
20
|
+
The outbound HTTP client is the injectable :data:`WebhookSender` seam (default: ``httpx``),
|
|
21
|
+
so tests drive the handler with no real network I/O — and ``httpx`` is imported only here, in
|
|
22
|
+
this capability, never by an app module.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import hmac
|
|
29
|
+
import json
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import UTC, datetime
|
|
34
|
+
from typing import Any, Final
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
from sqlmodel import Field
|
|
38
|
+
|
|
39
|
+
from terp.core import (
|
|
40
|
+
AppError,
|
|
41
|
+
BaseSchema,
|
|
42
|
+
JobContext,
|
|
43
|
+
JobDefinition,
|
|
44
|
+
JobVisibility,
|
|
45
|
+
NotFoundError,
|
|
46
|
+
RetryPolicy,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from terp.capabilities.webhooks.models import (
|
|
50
|
+
OUTCOME_BLOCKED,
|
|
51
|
+
OUTCOME_DELIVERED,
|
|
52
|
+
OUTCOME_FAILED,
|
|
53
|
+
OUTCOME_SKIPPED,
|
|
54
|
+
)
|
|
55
|
+
from terp.capabilities.webhooks.sealing import WebhookSecretError, unseal_secret
|
|
56
|
+
from terp.capabilities.webhooks.service import WebhookSubscriptionService
|
|
57
|
+
from terp.capabilities.webhooks.ssrf import (
|
|
58
|
+
PinnedTarget,
|
|
59
|
+
WebhookTargetError,
|
|
60
|
+
resolve_pinned_target,
|
|
61
|
+
)
|
|
62
|
+
from terp.capabilities.webhooks.store import record_delivery
|
|
63
|
+
|
|
64
|
+
# Conservative secure outbound limits (Tier-B-style knobs with safe defaults).
|
|
65
|
+
_TIMEOUT_SECONDS: Final[float] = 10.0
|
|
66
|
+
_MAX_PAYLOAD_BYTES: Final[int] = 256 * 1024 # 256 KiB cap on the signed body
|
|
67
|
+
_SIGNATURE_HEADER: Final[str] = "X-Terp-Signature"
|
|
68
|
+
_TIMESTAMP_HEADER: Final[str] = "X-Terp-Webhook-Timestamp"
|
|
69
|
+
_EVENT_HEADER: Final[str] = "X-Terp-Event"
|
|
70
|
+
_DELIVERY_HEADER: Final[str] = "X-Terp-Delivery-Id"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _utc_now() -> datetime:
|
|
74
|
+
"""UTC ``now`` provider for the signature timestamp (private so tests can patch it)."""
|
|
75
|
+
return datetime.now(UTC)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WebhookDeliveryError(AppError):
|
|
79
|
+
"""502 — a webhook delivery attempt failed; propagated so the outbox retries / dead-letters."""
|
|
80
|
+
|
|
81
|
+
status_code = 502
|
|
82
|
+
code = "webhook_delivery_failed"
|
|
83
|
+
default_message = "The webhook endpoint did not accept the delivery."
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class WebhookDeliveryPayload(BaseSchema):
|
|
87
|
+
"""The JSON-serializable payload carried by a ``WEBHOOK_DELIVER`` job (ids, not entities).
|
|
88
|
+
|
|
89
|
+
It carries the subscription id (the handler re-loads the live row to read the current
|
|
90
|
+
target / secret — neither rides the wire), a per-attempt-stable ``delivery_id`` the
|
|
91
|
+
subscriber can dedupe on, the originating event name, and the event's public ``data``.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
subscription_id: uuid.UUID
|
|
95
|
+
delivery_id: uuid.UUID
|
|
96
|
+
event: str = Field(max_length=128)
|
|
97
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class WebhookResponse:
|
|
102
|
+
"""The minimal result the handler needs from an outbound POST (the status code)."""
|
|
103
|
+
|
|
104
|
+
status_code: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# A sender performs the actual POST and returns a :class:`WebhookResponse`. It is injectable
|
|
108
|
+
# so tests drive the handler without real network I/O (mirroring the audit-sink / throttle-
|
|
109
|
+
# store seams); the default implementation uses ``httpx``.
|
|
110
|
+
WebhookSender = Callable[[PinnedTarget, bytes, dict[str, str]], WebhookResponse]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _httpx_sender(
|
|
114
|
+
target: PinnedTarget, body: bytes, headers: dict[str, str]
|
|
115
|
+
) -> WebhookResponse:
|
|
116
|
+
"""Default sender: POST to *target*, pinned to its pre-validated IP, no redirect chasing.
|
|
117
|
+
|
|
118
|
+
The request is built from the original ``https`` URL (so the ``Host`` header + path are
|
|
119
|
+
correct and TLS is verified against the hostname via the ``sni_hostname`` extension), then
|
|
120
|
+
the connection is **repointed to the validated IP** — so a DNS-rebinding attacker cannot
|
|
121
|
+
make the socket land on a private address after the SSRF check (closing the TOCTOU). A
|
|
122
|
+
strict timeout bounds the request; redirects are never followed.
|
|
123
|
+
"""
|
|
124
|
+
with httpx.Client(timeout=_TIMEOUT_SECONDS, follow_redirects=False) as client:
|
|
125
|
+
request = client.build_request(
|
|
126
|
+
"POST",
|
|
127
|
+
target.url,
|
|
128
|
+
content=body,
|
|
129
|
+
headers=headers,
|
|
130
|
+
extensions={"sni_hostname": target.host},
|
|
131
|
+
)
|
|
132
|
+
request.url = request.url.copy_with(host=target.ip)
|
|
133
|
+
response = client.send(request)
|
|
134
|
+
return WebhookResponse(status_code=response.status_code)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_active_sender: WebhookSender = _httpx_sender
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def set_webhook_sender(sender: WebhookSender) -> None:
|
|
141
|
+
"""Install the outbound HTTP *sender* (the seam tests replace with a fake)."""
|
|
142
|
+
global _active_sender
|
|
143
|
+
_active_sender = sender
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def reset_webhook_sender() -> None:
|
|
147
|
+
"""Restore the default ``httpx`` sender (the test-isolation reset)."""
|
|
148
|
+
global _active_sender
|
|
149
|
+
_active_sender = _httpx_sender
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def active_webhook_sender() -> WebhookSender:
|
|
153
|
+
"""The sender the delivery handler currently posts through."""
|
|
154
|
+
return _active_sender
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _sign(secret: str, timestamp: str, body: bytes) -> str:
|
|
158
|
+
"""``sha256=<hex>`` HMAC-SHA256 over ``timestamp.body``, keyed by the subscription *secret*.
|
|
159
|
+
|
|
160
|
+
Binding the timestamp into the signature lets a receiver reject a **replay** of a captured
|
|
161
|
+
delivery: it recomputes the HMAC over the ``X-Terp-Webhook-Timestamp`` header value and the
|
|
162
|
+
raw body and bounds the age, so a valid signature is not indefinitely reusable.
|
|
163
|
+
"""
|
|
164
|
+
signed = timestamp.encode("utf-8") + b"." + body
|
|
165
|
+
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
166
|
+
return f"sha256={digest}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_service = WebhookSubscriptionService()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def deliver_webhook(ctx: JobContext, payload: WebhookDeliveryPayload) -> None:
|
|
173
|
+
"""Deliver one webhook: load the subscription, sign, POST, record the attempt.
|
|
174
|
+
|
|
175
|
+
Runs in its own audited unit on a worker (``run_job``). A non-2xx response or a network
|
|
176
|
+
error records a ``failed`` attempt and then **raises** :class:`WebhookDeliveryError`, so
|
|
177
|
+
the outbox retries with backoff and dead-letters after the job's ``RetryPolicy`` budget.
|
|
178
|
+
A removed / inactive subscription, an SSRF-blocked target, or an oversized payload records
|
|
179
|
+
a terminal attempt and returns without raising (a retry could not help).
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
subscription = _service.get(ctx.session, payload.subscription_id)
|
|
183
|
+
except NotFoundError:
|
|
184
|
+
record_delivery(
|
|
185
|
+
ctx.session,
|
|
186
|
+
subscription_id=payload.subscription_id,
|
|
187
|
+
event=payload.event,
|
|
188
|
+
outcome=OUTCOME_SKIPPED,
|
|
189
|
+
attempt=ctx.attempt,
|
|
190
|
+
last_error="subscription no longer exists",
|
|
191
|
+
)
|
|
192
|
+
return
|
|
193
|
+
if not subscription.active:
|
|
194
|
+
record_delivery(
|
|
195
|
+
ctx.session,
|
|
196
|
+
subscription_id=subscription.id,
|
|
197
|
+
event=payload.event,
|
|
198
|
+
outcome=OUTCOME_SKIPPED,
|
|
199
|
+
attempt=ctx.attempt,
|
|
200
|
+
last_error="subscription is inactive",
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
body = json.dumps(payload.data, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
205
|
+
if len(body) > _MAX_PAYLOAD_BYTES:
|
|
206
|
+
record_delivery(
|
|
207
|
+
ctx.session,
|
|
208
|
+
subscription_id=subscription.id,
|
|
209
|
+
event=payload.event,
|
|
210
|
+
outcome=OUTCOME_FAILED,
|
|
211
|
+
attempt=ctx.attempt,
|
|
212
|
+
last_error=f"payload exceeds the {_MAX_PAYLOAD_BYTES}-byte cap",
|
|
213
|
+
)
|
|
214
|
+
return # an oversized payload is deterministic — do not waste retries on it
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
pinned = resolve_pinned_target(subscription.target_url)
|
|
218
|
+
except WebhookTargetError as exc:
|
|
219
|
+
record_delivery(
|
|
220
|
+
ctx.session,
|
|
221
|
+
subscription_id=subscription.id,
|
|
222
|
+
event=payload.event,
|
|
223
|
+
outcome=OUTCOME_BLOCKED,
|
|
224
|
+
attempt=ctx.attempt,
|
|
225
|
+
last_error=exc.message,
|
|
226
|
+
)
|
|
227
|
+
return # an SSRF-blocked target is deterministic — do not retry it
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# Sealed at rest (ADR 0076): the plaintext exists only here, at signing time.
|
|
231
|
+
signing_secret = unseal_secret(subscription.secret)
|
|
232
|
+
except WebhookSecretError as exc:
|
|
233
|
+
record_delivery(
|
|
234
|
+
ctx.session,
|
|
235
|
+
subscription_id=subscription.id,
|
|
236
|
+
event=payload.event,
|
|
237
|
+
outcome=OUTCOME_FAILED,
|
|
238
|
+
attempt=ctx.attempt,
|
|
239
|
+
last_error=exc.message,
|
|
240
|
+
)
|
|
241
|
+
return # a secret that no longer unseals is deterministic — do not retry it
|
|
242
|
+
|
|
243
|
+
timestamp = str(int(_utc_now().timestamp()))
|
|
244
|
+
headers = {
|
|
245
|
+
"Content-Type": "application/json",
|
|
246
|
+
_TIMESTAMP_HEADER: timestamp,
|
|
247
|
+
_SIGNATURE_HEADER: _sign(signing_secret, timestamp, body),
|
|
248
|
+
_EVENT_HEADER: payload.event,
|
|
249
|
+
_DELIVERY_HEADER: str(payload.delivery_id),
|
|
250
|
+
}
|
|
251
|
+
try:
|
|
252
|
+
response = active_webhook_sender()(pinned, body, headers)
|
|
253
|
+
except Exception as exc: # noqa: BLE001 - any transport error becomes a recorded, retried failure
|
|
254
|
+
record_delivery(
|
|
255
|
+
ctx.session,
|
|
256
|
+
subscription_id=subscription.id,
|
|
257
|
+
event=payload.event,
|
|
258
|
+
outcome=OUTCOME_FAILED,
|
|
259
|
+
response_code=None,
|
|
260
|
+
attempt=ctx.attempt,
|
|
261
|
+
last_error=f"request error: {exc}",
|
|
262
|
+
)
|
|
263
|
+
raise WebhookDeliveryError(
|
|
264
|
+
f"webhook request to subscription {subscription.id} failed"
|
|
265
|
+
) from exc
|
|
266
|
+
|
|
267
|
+
if 200 <= response.status_code < 300:
|
|
268
|
+
record_delivery(
|
|
269
|
+
ctx.session,
|
|
270
|
+
subscription_id=subscription.id,
|
|
271
|
+
event=payload.event,
|
|
272
|
+
outcome=OUTCOME_DELIVERED,
|
|
273
|
+
response_code=response.status_code,
|
|
274
|
+
attempt=ctx.attempt,
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
record_delivery(
|
|
278
|
+
ctx.session,
|
|
279
|
+
subscription_id=subscription.id,
|
|
280
|
+
event=payload.event,
|
|
281
|
+
outcome=OUTCOME_FAILED,
|
|
282
|
+
response_code=response.status_code,
|
|
283
|
+
attempt=ctx.attempt,
|
|
284
|
+
last_error=f"endpoint returned HTTP {response.status_code}",
|
|
285
|
+
)
|
|
286
|
+
raise WebhookDeliveryError(
|
|
287
|
+
f"webhook delivery to subscription {subscription.id} failed with "
|
|
288
|
+
f"HTTP {response.status_code}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# The typed job contract a consumer wires into its control-plane ``JobCatalog`` so the
|
|
293
|
+
# trigger can enqueue it (and the outbox worker resolve its handler by name). Retries lean
|
|
294
|
+
# on the outbox: five attempts with exponential backoff, then dead-letter.
|
|
295
|
+
WEBHOOK_DELIVER = JobDefinition(
|
|
296
|
+
name="webhooks.delivery.send",
|
|
297
|
+
payload_schema=WebhookDeliveryPayload,
|
|
298
|
+
handler=deliver_webhook,
|
|
299
|
+
retry=RetryPolicy(max_attempts=5, backoff_seconds=10.0),
|
|
300
|
+
queue="webhooks",
|
|
301
|
+
visibility=JobVisibility.INTERNAL,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
__all__ = [
|
|
306
|
+
"WEBHOOK_DELIVER",
|
|
307
|
+
"WebhookDeliveryError",
|
|
308
|
+
"WebhookDeliveryPayload",
|
|
309
|
+
"WebhookResponse",
|
|
310
|
+
"WebhookSender",
|
|
311
|
+
"active_webhook_sender",
|
|
312
|
+
"deliver_webhook",
|
|
313
|
+
"reset_webhook_sender",
|
|
314
|
+
"set_webhook_sender",
|
|
315
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""create webhooks tables
|
|
2
|
+
|
|
3
|
+
Revision ID: 717754ab63f6
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-06-30 22:16:37.385448
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = '717754ab63f6'
|
|
19
|
+
down_revision: str | None = None
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
26
|
+
op.create_table('webhook_delivery',
|
|
27
|
+
sa.Column('id', sa.Uuid(), nullable=False),
|
|
28
|
+
sa.Column('subscription_id', sa.Uuid(), nullable=False),
|
|
29
|
+
sa.Column('event', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
|
30
|
+
sa.Column('outcome', sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False),
|
|
31
|
+
sa.Column('response_code', sa.Integer(), nullable=True),
|
|
32
|
+
sa.Column('attempt', sa.Integer(), nullable=False),
|
|
33
|
+
sa.Column('last_error', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True),
|
|
34
|
+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
35
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_webhook_delivery'))
|
|
36
|
+
)
|
|
37
|
+
with op.batch_alter_table('webhook_delivery', schema=None) as batch_op:
|
|
38
|
+
batch_op.create_index(batch_op.f('ix_webhook_delivery_created_at'), ['created_at'], unique=False)
|
|
39
|
+
batch_op.create_index(batch_op.f('ix_webhook_delivery_event'), ['event'], unique=False)
|
|
40
|
+
batch_op.create_index(batch_op.f('ix_webhook_delivery_outcome'), ['outcome'], unique=False)
|
|
41
|
+
batch_op.create_index(batch_op.f('ix_webhook_delivery_subscription_id'), ['subscription_id'], unique=False)
|
|
42
|
+
|
|
43
|
+
op.create_table('webhook_subscription',
|
|
44
|
+
sa.Column('owner_id', sa.Uuid(), nullable=True),
|
|
45
|
+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
46
|
+
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
47
|
+
sa.Column('id', sa.Uuid(), nullable=False),
|
|
48
|
+
sa.Column('version', sa.Integer(), nullable=False),
|
|
49
|
+
sa.Column('target_url', sqlmodel.sql.sqltypes.AutoString(length=2048), nullable=False),
|
|
50
|
+
sa.Column('secret', sqlmodel.sql.sqltypes.AutoString(length=256), nullable=False),
|
|
51
|
+
sa.Column('event', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
|
52
|
+
sa.Column('active', sa.Boolean(), nullable=False),
|
|
53
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_webhook_subscription'))
|
|
54
|
+
)
|
|
55
|
+
with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
|
|
56
|
+
batch_op.create_index(batch_op.f('ix_webhook_subscription_active'), ['active'], unique=False)
|
|
57
|
+
batch_op.create_index(batch_op.f('ix_webhook_subscription_event'), ['event'], unique=False)
|
|
58
|
+
batch_op.create_index(batch_op.f('ix_webhook_subscription_owner_id'), ['owner_id'], unique=False)
|
|
59
|
+
batch_op.create_index(batch_op.f('ix_webhook_subscription_target_url'), ['target_url'], unique=False)
|
|
60
|
+
|
|
61
|
+
# ### end Alembic commands ###
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def downgrade() -> None:
|
|
65
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
66
|
+
with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
|
|
67
|
+
batch_op.drop_index(batch_op.f('ix_webhook_subscription_target_url'))
|
|
68
|
+
batch_op.drop_index(batch_op.f('ix_webhook_subscription_owner_id'))
|
|
69
|
+
batch_op.drop_index(batch_op.f('ix_webhook_subscription_event'))
|
|
70
|
+
batch_op.drop_index(batch_op.f('ix_webhook_subscription_active'))
|
|
71
|
+
|
|
72
|
+
op.drop_table('webhook_subscription')
|
|
73
|
+
with op.batch_alter_table('webhook_delivery', schema=None) as batch_op:
|
|
74
|
+
batch_op.drop_index(batch_op.f('ix_webhook_delivery_subscription_id'))
|
|
75
|
+
batch_op.drop_index(batch_op.f('ix_webhook_delivery_outcome'))
|
|
76
|
+
batch_op.drop_index(batch_op.f('ix_webhook_delivery_event'))
|
|
77
|
+
batch_op.drop_index(batch_op.f('ix_webhook_delivery_created_at'))
|
|
78
|
+
|
|
79
|
+
op.drop_table('webhook_delivery')
|
|
80
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""widen webhook secret for at-rest sealing
|
|
2
|
+
|
|
3
|
+
Revision ID: 8f1c4a2d9b3e
|
|
4
|
+
Revises: 717754ab63f6
|
|
5
|
+
Create Date: 2026-07-06 20:30:00.000000
|
|
6
|
+
|
|
7
|
+
The subscription's signing ``secret`` is now sealed before it is persisted
|
|
8
|
+
(ADR 0076): the stored value is the ``enc:v1:`` Fernet ciphertext of the
|
|
9
|
+
client-supplied secret, whose seal of the schema's 256-char maximum input is
|
|
10
|
+
~447 characters — so the column widens from 256 to 512. A widening VARCHAR
|
|
11
|
+
change loses no data; existing (legacy plaintext) rows are untouched and keep
|
|
12
|
+
delivering — the runtime unseal passes an unsealed value through unchanged.
|
|
13
|
+
"""
|
|
14
|
+
# terp-allow-destructive-migration: widening webhook_subscription.secret 256 -> 512 (a pure widen — no value can be truncated) so the sealed-at-rest ciphertext fits (ADR 0076)
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from alembic import op
|
|
20
|
+
import sqlmodel
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# revision identifiers, used by Alembic.
|
|
24
|
+
revision: str = '8f1c4a2d9b3e'
|
|
25
|
+
down_revision: str | None = '717754ab63f6'
|
|
26
|
+
branch_labels: str | Sequence[str] | None = None
|
|
27
|
+
depends_on: str | Sequence[str] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def upgrade() -> None:
|
|
31
|
+
with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
|
|
32
|
+
# arch-allow-no-destructive-migrations: widening secret 256 -> 512 (a pure widen — no value can be truncated) so the sealed-at-rest ciphertext fits (ADR 0076)
|
|
33
|
+
batch_op.alter_column(
|
|
34
|
+
'secret',
|
|
35
|
+
existing_type=sqlmodel.sql.sqltypes.AutoString(length=256),
|
|
36
|
+
type_=sqlmodel.sql.sqltypes.AutoString(length=512),
|
|
37
|
+
existing_nullable=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def downgrade() -> None:
|
|
42
|
+
with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
|
|
43
|
+
batch_op.alter_column(
|
|
44
|
+
'secret',
|
|
45
|
+
existing_type=sqlmodel.sql.sqltypes.AutoString(length=512),
|
|
46
|
+
type_=sqlmodel.sql.sqltypes.AutoString(length=256),
|
|
47
|
+
existing_nullable=False,
|
|
48
|
+
)
|