Files
gh-christianlouis-dmarq/backend/app/services/webhook_events.py
T
2026-05-23 18:11:44 +02:00

466 lines
16 KiB
Python

"""Outbound webhook event creation, signing, and delivery."""
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from urllib.parse import urlparse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret
from app.models.webhook import WebhookDelivery, WebhookEndpoint
EVENT_REPORT_IMPORTED = "dmarq.report.imported"
EVENT_SENDER_NEW = "dmarq.sender.new"
EVENT_COMPLIANCE_DROP = "dmarq.compliance.drop"
EVENT_REPORTS_MISSING = "dmarq.reports.missing"
EVENT_ALERT_CREATED = "dmarq.alert.created"
EVENT_ALERT_RESOLVED = "dmarq.alert.resolved"
EVENT_WEBHOOK_TEST = "dmarq.webhook.test"
SUPPORTED_EVENT_TYPES = [
EVENT_REPORT_IMPORTED,
EVENT_SENDER_NEW,
EVENT_COMPLIANCE_DROP,
EVENT_REPORTS_MISSING,
EVENT_ALERT_CREATED,
EVENT_ALERT_RESOLVED,
EVENT_WEBHOOK_TEST,
]
DELIVERY_PENDING = "pending"
DELIVERY_DELIVERED = "delivered"
DELIVERY_FAILED = "failed"
DELIVERY_ABANDONED = "abandoned"
@dataclass
class DeliveryAttemptResult:
"""Result returned by a webhook HTTP sender."""
status_code: int
body: str = ""
WebhookSender = Callable[[str, bytes, Dict[str, str], int], DeliveryAttemptResult]
def normalize_event_types(event_types: Iterable[str]) -> List[str]:
"""Return validated event types for endpoint storage."""
cleaned = sorted({item.strip() for item in event_types if item and item.strip()})
if not cleaned:
return ["*"]
if "*" in cleaned:
return ["*"]
invalid = [item for item in cleaned if item not in SUPPORTED_EVENT_TYPES]
if invalid:
raise ValueError(f"Unsupported webhook event type: {', '.join(invalid)}")
return cleaned
def event_types_to_string(event_types: Iterable[str]) -> str:
"""Serialize event types for storage."""
return ",".join(normalize_event_types(event_types))
def parse_event_types(value: str) -> List[str]:
"""Parse stored event types."""
return normalize_event_types((value or "*").split(","))
def endpoint_matches_event(endpoint: WebhookEndpoint, event_type: str) -> bool:
"""Return True when an endpoint should receive an event."""
event_types = parse_event_types(endpoint.event_types)
return "*" in event_types or event_type in event_types
def generate_webhook_secret() -> str:
"""Generate a signing secret for outbound webhook deliveries."""
return secrets.token_urlsafe(32)
def _encrypt(value: str) -> str:
return encrypt_secret(value) or ""
def _decrypt(value: str) -> str:
return decrypt_secret(value) or ""
def _redact_url(url: str) -> str:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return "[invalid url]"
host = parsed.hostname or parsed.netloc
port = f":{parsed.port}" if parsed.port else ""
path = parsed.path or "/"
return f"{parsed.scheme}://{host}{port}{path}"
def validate_webhook_url(url: str) -> str:
"""Validate and normalize an outbound webhook URL."""
clean_url = url.strip()
parsed = urlparse(clean_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("Webhook URL must be an absolute http or https URL")
return clean_url
def create_webhook_endpoint(
db: Session,
*,
name: str,
url: str,
secret: Optional[str] = None,
event_types: Iterable[str] = ("*",),
enabled: bool = True,
max_attempts: int = 5,
timeout_seconds: int = 10,
) -> Tuple[WebhookEndpoint, str]:
"""Create a webhook endpoint and return the endpoint plus raw signing secret."""
clean_name = name.strip()
if not clean_name:
raise ValueError("Webhook name is required")
clean_url = validate_webhook_url(url)
raw_secret = secret.strip() if secret else generate_webhook_secret()
if len(raw_secret) < 16:
raise ValueError("Webhook signing secret must be at least 16 characters")
endpoint = WebhookEndpoint(
name=clean_name,
url=_encrypt(clean_url),
secret=_encrypt(raw_secret),
event_types=event_types_to_string(event_types),
enabled=enabled,
max_attempts=max(1, min(int(max_attempts or 5), 10)),
timeout_seconds=max(1, min(int(timeout_seconds or 10), 30)),
)
db.add(endpoint)
db.commit()
db.refresh(endpoint)
return endpoint, raw_secret
def update_webhook_endpoint(
db: Session,
endpoint: WebhookEndpoint,
*,
name: Optional[str] = None,
url: Optional[str] = None,
secret: Optional[str] = None,
event_types: Optional[Iterable[str]] = None,
enabled: Optional[bool] = None,
max_attempts: Optional[int] = None,
timeout_seconds: Optional[int] = None,
) -> Tuple[WebhookEndpoint, Optional[str]]:
"""Update a webhook endpoint. Return the endpoint and newly supplied/generated secret."""
returned_secret = None
if name is not None:
clean_name = name.strip()
if not clean_name:
raise ValueError("Webhook name is required")
endpoint.name = clean_name
if url is not None and url.strip() and url != "**redacted**":
endpoint.url = _encrypt(validate_webhook_url(url))
if secret is not None and secret.strip() and secret != "**redacted**":
returned_secret = secret.strip()
if len(returned_secret) < 16:
raise ValueError("Webhook signing secret must be at least 16 characters")
endpoint.secret = _encrypt(returned_secret)
if event_types is not None:
endpoint.event_types = event_types_to_string(event_types)
if enabled is not None:
endpoint.enabled = enabled
if max_attempts is not None:
endpoint.max_attempts = max(1, min(int(max_attempts), 10))
if timeout_seconds is not None:
endpoint.timeout_seconds = max(1, min(int(timeout_seconds), 30))
db.commit()
db.refresh(endpoint)
return endpoint, returned_secret
def _stable_json(value: Dict[str, Any]) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
def build_event_payload(event_type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Wrap event data in a stable, documented envelope."""
now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
return {
"event_type": event_type,
"created_at": now,
"data": payload,
}
def default_idempotency_key(event_type: str, payload: Dict[str, Any]) -> str:
"""Build a deterministic idempotency key for an event payload."""
digest = hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()[:32]
return f"{event_type}:{digest}"
def enqueue_webhook_event(
db: Session,
*,
event_type: str,
payload: Dict[str, Any],
idempotency_key: Optional[str] = None,
) -> List[WebhookDelivery]:
"""Create pending deliveries for all enabled endpoints matching an event."""
if event_type not in SUPPORTED_EVENT_TYPES:
raise ValueError(f"Unsupported webhook event type: {event_type}")
endpoints = db.query(WebhookEndpoint).filter(WebhookEndpoint.enabled.is_(True)).all()
event_payload = build_event_payload(event_type, payload)
key = idempotency_key or default_idempotency_key(event_type, event_payload)
deliveries: List[WebhookDelivery] = []
for endpoint in endpoints:
if not endpoint_matches_event(endpoint, event_type):
continue
delivery = WebhookDelivery(
endpoint_id=endpoint.id,
event_type=event_type,
payload=_stable_json(event_payload),
idempotency_key=key,
status=DELIVERY_PENDING,
max_attempts=endpoint.max_attempts,
)
db.add(delivery)
try:
db.commit()
except IntegrityError:
db.rollback()
existing = (
db.query(WebhookDelivery)
.filter(
WebhookDelivery.endpoint_id == endpoint.id,
WebhookDelivery.idempotency_key == key,
)
.first()
)
if existing:
deliveries.append(existing)
continue
db.refresh(delivery)
deliveries.append(delivery)
return deliveries
def _delivery_body(delivery: WebhookDelivery) -> bytes:
return delivery.payload.encode("utf-8")
def sign_delivery(secret: str, delivery: WebhookDelivery, timestamp: int, body: bytes) -> str:
"""Return the v1 HMAC signature for a delivery."""
signed = f"{timestamp}.{delivery.id}.".encode("utf-8") + body
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return f"v1={digest}"
def build_delivery_headers(endpoint: WebhookEndpoint, delivery: WebhookDelivery) -> Dict[str, str]:
"""Build outbound webhook headers with event metadata and HMAC signature."""
body = _delivery_body(delivery)
timestamp = int(datetime.utcnow().timestamp())
secret = _decrypt(endpoint.secret)
return {
"Content-Type": "application/json",
"User-Agent": "DMARQ-Webhooks/1.0",
"X-DMARQ-Event": delivery.event_type,
"X-DMARQ-Delivery": str(delivery.id),
"X-DMARQ-Idempotency-Key": delivery.idempotency_key,
"X-DMARQ-Timestamp": str(timestamp),
"X-DMARQ-Signature": sign_delivery(secret, delivery, timestamp, body),
}
def default_webhook_sender(
url: str, body: bytes, headers: Dict[str, str], timeout_seconds: int
) -> DeliveryAttemptResult:
"""Send a webhook delivery using the Python standard library."""
request = urllib.request.Request(url=url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response: # nosec B310
response_body = response.read(4096).decode("utf-8", errors="replace")
return DeliveryAttemptResult(status_code=response.status, body=response_body)
except urllib.error.HTTPError as exc:
response_body = exc.read(4096).decode("utf-8", errors="replace")
return DeliveryAttemptResult(status_code=exc.code, body=response_body)
except urllib.error.URLError as exc:
raise ConnectionError(str(exc.reason)) from exc
def _backoff_for_attempt(attempt_count: int) -> timedelta:
seconds = min(3600, 60 * (2 ** max(0, attempt_count - 1)))
return timedelta(seconds=seconds)
def _response_excerpt(value: str) -> str:
return (value or "")[:500]
def deliver_webhook_delivery(
db: Session,
delivery: WebhookDelivery,
*,
sender: WebhookSender = default_webhook_sender,
) -> WebhookDelivery:
"""Attempt one webhook delivery and persist retry state."""
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == delivery.endpoint_id).first()
now = datetime.utcnow()
delivery.attempt_count = int(delivery.attempt_count or 0) + 1
delivery.last_attempt_at = now
if endpoint is None or not endpoint.enabled:
delivery.status = DELIVERY_ABANDONED
delivery.last_error = "Webhook endpoint is disabled or missing."
delivery.next_attempt_at = now
db.commit()
db.refresh(delivery)
return delivery
try:
body = _delivery_body(delivery)
result = sender(
_decrypt(endpoint.url),
body,
build_delivery_headers(endpoint, delivery),
endpoint.timeout_seconds,
)
delivery.last_status_code = result.status_code
delivery.response_excerpt = _response_excerpt(result.body)
if 200 <= result.status_code < 300:
delivery.status = DELIVERY_DELIVERED
delivery.delivered_at = now
delivery.last_error = None
delivery.next_attempt_at = now
endpoint.last_success_at = now
endpoint.failure_count = 0
else:
delivery.last_error = f"HTTP {result.status_code}"
_mark_delivery_failure(delivery, endpoint, now)
except Exception as exc: # pylint: disable=broad-exception-caught
delivery.last_error = str(exc)[:500]
_mark_delivery_failure(delivery, endpoint, now)
db.commit()
db.refresh(delivery)
return delivery
def _mark_delivery_failure(
delivery: WebhookDelivery, endpoint: WebhookEndpoint, now: datetime
) -> None:
endpoint.last_failure_at = now
endpoint.failure_count = int(endpoint.failure_count or 0) + 1
if delivery.attempt_count >= delivery.max_attempts:
delivery.status = DELIVERY_FAILED
delivery.next_attempt_at = now
return
delivery.status = DELIVERY_PENDING
delivery.next_attempt_at = now + _backoff_for_attempt(delivery.attempt_count)
def deliver_due_webhooks(
db: Session,
*,
limit: int = 25,
endpoint_id: Optional[int] = None,
sender: WebhookSender = default_webhook_sender,
) -> List[WebhookDelivery]:
"""Deliver pending webhook deliveries whose retry time has arrived."""
now = datetime.utcnow()
query = db.query(WebhookDelivery).filter(
WebhookDelivery.status == DELIVERY_PENDING,
WebhookDelivery.next_attempt_at <= now,
)
if endpoint_id is not None:
query = query.filter(WebhookDelivery.endpoint_id == endpoint_id)
deliveries = (
query.order_by(WebhookDelivery.next_attempt_at, WebhookDelivery.id).limit(limit).all()
)
return [deliver_webhook_delivery(db, delivery, sender=sender) for delivery in deliveries]
def queue_test_webhook(db: Session, endpoint_id: int) -> WebhookDelivery:
"""Queue a one-off test delivery for a specific webhook endpoint."""
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
if endpoint is None:
raise ValueError("Webhook endpoint not found")
payload = {
"endpoint_id": endpoint.id,
"endpoint_name": endpoint.name,
"message": "DMARQ webhook test delivery",
}
delivery = WebhookDelivery(
endpoint_id=endpoint.id,
event_type=EVENT_WEBHOOK_TEST,
payload=_stable_json(build_event_payload(EVENT_WEBHOOK_TEST, payload)),
idempotency_key=f"{EVENT_WEBHOOK_TEST}:{secrets.token_hex(16)}",
status=DELIVERY_PENDING,
max_attempts=endpoint.max_attempts,
)
db.add(delivery)
db.commit()
db.refresh(delivery)
return delivery
def endpoint_to_dict(endpoint: WebhookEndpoint) -> Dict[str, Any]:
"""Return an API-safe webhook endpoint representation."""
raw_url = _decrypt(endpoint.url)
return {
"id": endpoint.id,
"name": endpoint.name,
"url": _redact_url(raw_url),
"event_types": parse_event_types(endpoint.event_types),
"enabled": endpoint.enabled,
"max_attempts": endpoint.max_attempts,
"timeout_seconds": endpoint.timeout_seconds,
"created_at": endpoint.created_at.isoformat() if endpoint.created_at else None,
"updated_at": endpoint.updated_at.isoformat() if endpoint.updated_at else None,
"last_success_at": (
endpoint.last_success_at.isoformat() if endpoint.last_success_at else None
),
"last_failure_at": (
endpoint.last_failure_at.isoformat() if endpoint.last_failure_at else None
),
"failure_count": endpoint.failure_count,
"secret_configured": bool(endpoint.secret),
"url_encrypted": is_encrypted_secret(endpoint.url),
}
def delivery_to_dict(delivery: WebhookDelivery) -> Dict[str, Any]:
"""Return an API-safe delivery representation."""
return {
"id": delivery.id,
"endpoint_id": delivery.endpoint_id,
"event_type": delivery.event_type,
"idempotency_key": delivery.idempotency_key,
"status": delivery.status,
"attempt_count": delivery.attempt_count,
"max_attempts": delivery.max_attempts,
"next_attempt_at": (
delivery.next_attempt_at.isoformat() if delivery.next_attempt_at else None
),
"last_attempt_at": (
delivery.last_attempt_at.isoformat() if delivery.last_attempt_at else None
),
"delivered_at": delivery.delivered_at.isoformat() if delivery.delivered_at else None,
"last_status_code": delivery.last_status_code,
"last_error": delivery.last_error,
"response_excerpt": delivery.response_excerpt,
"created_at": delivery.created_at.isoformat() if delivery.created_at else None,
"updated_at": delivery.updated_at.isoformat() if delivery.updated_at else None,
}