feat: add outbound webhook event framework

This commit is contained in:
Christian Krakau-Louis
2026-05-23 18:06:29 +02:00
parent aedd868a9c
commit d61a05bad0
17 changed files with 1674 additions and 14 deletions
+6 -1
View File
@@ -29,7 +29,11 @@ from app.services.alert_history import (
record_alert_config_change,
record_alert_evaluation,
)
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
from app.services.alert_rules import (
enqueue_alert_webhook_events,
evaluate_alert_rules,
send_current_alerts,
)
from app.services.notifications import send_notification
from app.services.summary_notifications import build_summary, send_summary_notification
@@ -496,6 +500,7 @@ async def evaluate_notification_alerts(
_seed_defaults(db)
alerts = evaluate_alert_rules(db)
record_alert_evaluation(db, alerts)
enqueue_alert_webhook_events(db, alerts)
return {"alerts": alerts}
@@ -17,6 +17,7 @@ from app.core.redaction import sanitize_for_log
from app.services.dmarc_parser import DMARCParser
from app.services.report_persistence import report_exists, save_parsed_report
from app.services.report_store import ReportStore
from app.services.webhook_events import EVENT_REPORT_IMPORTED, enqueue_webhook_event
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -69,6 +70,22 @@ def _store_report(db: Session, store: ReportStore, report: Dict[str, Any]) -> st
if report_id and (store.has_report(domain, report_id) or report_exists(db, domain, report_id)):
return "duplicate"
save_parsed_report(db, report)
try:
enqueue_webhook_event(
db,
event_type=EVENT_REPORT_IMPORTED,
payload={
"domain": domain,
"report_id": report_id,
"org_name": report.get("org_name"),
"begin_date": report.get("begin_date"),
"end_date": report.get("end_date"),
"records": len(report.get("records") or []),
},
idempotency_key=f"{EVENT_REPORT_IMPORTED}:{domain}:{report_id or 'unknown'}",
)
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("Failed to queue report-import webhook event: %s", sanitize_for_log(exc))
store.add_report(report)
return "imported"
@@ -0,0 +1,228 @@
"""Admin endpoints for outbound webhook event delivery."""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.webhook import WebhookDelivery, WebhookEndpoint
from app.services.webhook_events import (
SUPPORTED_EVENT_TYPES,
create_webhook_endpoint,
deliver_due_webhooks,
delivery_to_dict,
endpoint_to_dict,
queue_test_webhook,
update_webhook_endpoint,
)
router = APIRouter()
class WebhookEndpointCreate(BaseModel):
"""Create payload for outbound webhook endpoints."""
name: str
url: str
secret: Optional[str] = None
event_types: List[str] = ["*"]
enabled: bool = True
max_attempts: int = 5
timeout_seconds: int = 10
class WebhookEndpointUpdate(BaseModel):
"""Update payload for outbound webhook endpoints."""
name: Optional[str] = None
url: Optional[str] = None
secret: Optional[str] = None
event_types: Optional[List[str]] = None
enabled: Optional[bool] = None
max_attempts: Optional[int] = None
timeout_seconds: Optional[int] = None
class WebhookEndpointResponse(BaseModel):
"""API-safe webhook endpoint metadata."""
id: int
name: str
url: str
event_types: List[str]
enabled: bool
max_attempts: int
timeout_seconds: int
created_at: Optional[str]
updated_at: Optional[str]
last_success_at: Optional[str]
last_failure_at: Optional[str]
failure_count: int
secret_configured: bool
url_encrypted: bool
secret: Optional[str] = None
class WebhookEndpointListResponse(BaseModel):
"""List response for outbound webhook endpoints."""
endpoints: List[WebhookEndpointResponse]
supported_event_types: List[str]
class WebhookDeliveryResponse(BaseModel):
"""API-safe webhook delivery metadata."""
id: int
endpoint_id: int
event_type: str
idempotency_key: str
status: str
attempt_count: int
max_attempts: int
next_attempt_at: Optional[str]
last_attempt_at: Optional[str]
delivered_at: Optional[str]
last_status_code: Optional[int]
last_error: Optional[str]
response_excerpt: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
class WebhookDeliveryListResponse(BaseModel):
"""List response for outbound webhook deliveries."""
deliveries: List[WebhookDeliveryResponse]
class WebhookTestResponse(BaseModel):
"""Response for a webhook test delivery."""
delivery: WebhookDeliveryResponse
@router.get("", response_model=WebhookEndpointListResponse)
async def list_webhook_endpoints(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Return configured outbound webhook endpoints."""
endpoints = db.query(WebhookEndpoint).order_by(WebhookEndpoint.created_at.desc()).all()
return {
"endpoints": [endpoint_to_dict(endpoint) for endpoint in endpoints],
"supported_event_types": SUPPORTED_EVENT_TYPES,
}
@router.post("", response_model=WebhookEndpointResponse)
async def create_webhook(
payload: WebhookEndpointCreate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Create an outbound webhook endpoint."""
try:
endpoint, raw_secret = create_webhook_endpoint(db, **payload.model_dump())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
body = endpoint_to_dict(endpoint)
body["secret"] = raw_secret
return body
@router.put("/{endpoint_id}", response_model=WebhookEndpointResponse)
async def update_webhook(
endpoint_id: int,
payload: WebhookEndpointUpdate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Update an outbound webhook endpoint."""
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
if endpoint is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Webhook endpoint not found"
)
try:
endpoint, raw_secret = update_webhook_endpoint(
db,
endpoint,
**payload.model_dump(exclude_unset=True),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
body = endpoint_to_dict(endpoint)
body["secret"] = raw_secret
return body
@router.delete("/{endpoint_id}", response_model=WebhookEndpointResponse)
async def disable_webhook(
endpoint_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Disable a webhook endpoint without deleting delivery history."""
endpoint = db.query(WebhookEndpoint).filter(WebhookEndpoint.id == endpoint_id).first()
if endpoint is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Webhook endpoint not found"
)
endpoint.enabled = False
db.commit()
db.refresh(endpoint)
body = endpoint_to_dict(endpoint)
body["secret"] = None
return body
@router.get("/deliveries", response_model=WebhookDeliveryListResponse)
async def list_webhook_deliveries(
endpoint_id: Optional[int] = None,
delivery_status: Optional[str] = Query(None, alias="status"),
limit: int = 50,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Return recent outbound webhook deliveries."""
query = db.query(WebhookDelivery)
if endpoint_id is not None:
query = query.filter(WebhookDelivery.endpoint_id == endpoint_id)
if delivery_status:
query = query.filter(WebhookDelivery.status == delivery_status)
deliveries = (
query.order_by(WebhookDelivery.created_at.desc(), WebhookDelivery.id.desc())
.limit(max(1, min(limit, 200)))
.all()
)
return {"deliveries": [delivery_to_dict(delivery) for delivery in deliveries]}
@router.post("/{endpoint_id}/test", response_model=WebhookTestResponse)
async def test_webhook(
endpoint_id: int,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Queue and immediately attempt a test delivery for an endpoint."""
try:
delivery = queue_test_webhook(db, endpoint_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
delivered = deliver_due_webhooks(db, endpoint_id=endpoint_id, limit=1)
return {"delivery": delivery_to_dict(delivered[0] if delivered else delivery)}
@router.post("/deliveries/process", response_model=WebhookDeliveryListResponse)
async def process_due_webhooks(
limit: int = 25,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> Dict[str, Any]:
"""Attempt due pending webhook deliveries."""
deliveries = deliver_due_webhooks(db, limit=max(1, min(limit, 100)))
return {"deliveries": [delivery_to_dict(delivery) for delivery in deliveries]}