feat(phases): implement webhooks (6.1), email (7.1), user preferences (7.2)
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Phase 6.1: WebhookConfig model, CRUD router (/api/v1/webhooks, admin-only), dispatch_webhook() with HMAC signing; integrated into test validation, campaign completion, and MITRE sync job - Phase 7.1: SMTP email service with send_test_validated_email, send_campaign_completed_email, send_new_mitre_techniques_email; notify_role_with_email() added to notification_service - Phase 7.2: notification_preferences and jira_account_id on User model; PATCH /users/me/preferences endpoint; Alembic migrations b031phase6 and b032phase7 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
145
backend/app/services/webhook_service.py
Normal file
145
backend/app/services/webhook_service.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Webhook dispatch service — outbound HTTP notifications.
|
||||
|
||||
Supported event types:
|
||||
- test.validated — fired when a test reaches "validated" state
|
||||
- test.rejected — fired when a test reaches "rejected" state
|
||||
- campaign.completed — fired when a campaign is completed
|
||||
- campaign.started — fired when a campaign is activated
|
||||
- mitre.synced — fired after MITRE ATT&CK sync completes
|
||||
- technique.status_changed — fired when a technique's status changes
|
||||
- webhook.test — manual test ping from the admin UI
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.webhook_config import WebhookConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dispatch_webhook(event_type: str, payload: dict) -> None:
|
||||
"""Send an outbound webhook to all active subscribers for *event_type*.
|
||||
|
||||
Opens its own DB session so this can be called outside request context
|
||||
(e.g. from background jobs). All exceptions are caught; webhook failures
|
||||
never crash the caller.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
webhooks = (
|
||||
db.query(WebhookConfig)
|
||||
.filter(WebhookConfig.is_active == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
for wh in webhooks:
|
||||
# Filter by subscribed events — empty list means "all events"
|
||||
subscribed: list = wh.events or []
|
||||
if subscribed and event_type not in subscribed:
|
||||
continue
|
||||
_send_webhook(db, wh, event_type, payload)
|
||||
except Exception:
|
||||
logger.exception("dispatch_webhook: unexpected error for event_type=%s", event_type)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None:
|
||||
"""Send a single webhook POST and update its metadata."""
|
||||
body = {
|
||||
"event": event_type,
|
||||
"data": payload,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if wh.secret:
|
||||
import json
|
||||
raw = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
|
||||
sig = hmac.new(wh.secret.encode(), raw, hashlib.sha256).hexdigest()
|
||||
headers["X-Aegis-Signature"] = f"sha256={sig}"
|
||||
try:
|
||||
resp = requests.post(wh.url, json=body, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
wh.last_triggered_at = datetime.utcnow()
|
||||
wh.failure_count = 0
|
||||
db.commit()
|
||||
logger.info("Webhook '%s' (%s) dispatched OK for event=%s", wh.name, wh.url, event_type)
|
||||
except Exception as exc:
|
||||
wh.failure_count = (wh.failure_count or 0) + 1
|
||||
wh.last_triggered_at = datetime.utcnow()
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.warning(
|
||||
"Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)",
|
||||
wh.name, wh.url, event_type, exc, wh.failure_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_webhooks(db, *, offset: int = 0, limit: int = 50) -> list[WebhookConfig]:
|
||||
"""Return paginated webhook configs."""
|
||||
return (
|
||||
db.query(WebhookConfig)
|
||||
.order_by(WebhookConfig.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_webhook_or_raise(db, webhook_id: uuid.UUID) -> WebhookConfig:
|
||||
"""Fetch a webhook by ID or raise 404."""
|
||||
from app.domain.errors import EntityNotFoundError
|
||||
wh = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first()
|
||||
if wh is None:
|
||||
raise EntityNotFoundError("WebhookConfig", str(webhook_id))
|
||||
return wh
|
||||
|
||||
|
||||
def create_webhook(db, created_by: uuid.UUID, payload) -> WebhookConfig:
|
||||
"""Create and persist a new WebhookConfig."""
|
||||
wh = WebhookConfig(
|
||||
name=payload.name,
|
||||
url=payload.url,
|
||||
secret=payload.secret,
|
||||
events=payload.events,
|
||||
is_active=payload.is_active,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(wh)
|
||||
db.flush()
|
||||
return wh
|
||||
|
||||
|
||||
def update_webhook(db, webhook_id: uuid.UUID, payload) -> WebhookConfig:
|
||||
"""Apply a partial update to an existing WebhookConfig."""
|
||||
wh = get_webhook_or_raise(db, webhook_id)
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(wh, field, value)
|
||||
db.flush()
|
||||
return wh
|
||||
|
||||
|
||||
def delete_webhook(db, webhook_id: uuid.UUID) -> None:
|
||||
"""Hard-delete a WebhookConfig."""
|
||||
wh = get_webhook_or_raise(db, webhook_id)
|
||||
db.delete(wh)
|
||||
db.flush()
|
||||
Reference in New Issue
Block a user