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

- 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:
kitos
2026-05-19 13:40:45 +02:00
parent d6df7fdc09
commit c1e06d4c0a
16 changed files with 590 additions and 2 deletions

View File

@@ -0,0 +1,79 @@
"""Email notification service using SMTP.
Sending is silently skipped when SMTP_ENABLED=False (default).
All errors are caught and logged — email failures never crash the caller.
"""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.config import settings
logger = logging.getLogger(__name__)
def send_email(to: str, subject: str, html_body: str) -> bool:
"""Send an HTML email. Returns True on success, False on skip/error."""
if not settings.SMTP_ENABLED:
logger.debug("SMTP disabled — skipping email to %s: %s", to, subject)
return False
if not to:
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Aegis] {subject}"
msg["From"] = settings.SMTP_FROM_EMAIL
msg["To"] = to
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server:
if settings.SMTP_USE_TLS:
server.starttls()
if settings.SMTP_USERNAME:
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
server.send_message(msg)
logger.info("Email sent to %s: %s", to, subject)
return True
except Exception:
logger.exception("Failed to send email to %s: %s", to, subject)
return False
def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str) -> bool:
"""Notify that a test was validated."""
url = f"{settings.PLATFORM_URL}/tests/{test_id}"
html = f"""
<html><body style="font-family:sans-serif;color:#1a1a2e">
<h2 style="color:#22d3ee">&#x2705; Test Validated</h2>
<p>Test <strong>{test_name}</strong> for technique <code>{technique_id}</code> has been validated.</p>
<p><a href="{url}" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Test</a></p>
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
</body></html>"""
return send_email(to, f"Test Validated: {test_name}", html)
def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str) -> bool:
"""Notify that a campaign was completed."""
url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}"
html = f"""
<html><body style="font-family:sans-serif;color:#1a1a2e">
<h2 style="color:#22d3ee">&#x1F3AF; Campaign Completed</h2>
<p>Campaign <strong>{campaign_name}</strong> has been completed.</p>
<p><a href="{url}" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Campaign</a></p>
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
</body></html>"""
return send_email(to, f"Campaign Completed: {campaign_name}", html)
def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool:
"""Notify of new MITRE techniques after sync."""
if created == 0:
return False
html = f"""
<html><body style="font-family:sans-serif;color:#1a1a2e">
<h2 style="color:#22d3ee">&#x1F504; MITRE ATT&CK Updated</h2>
<p><strong>{created}</strong> new techniques added, <strong>{updated}</strong> updated.</p>
<p><a href="{settings.PLATFORM_URL}/techniques" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Techniques</a></p>
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
</body></html>"""
return send_email(to, f"MITRE ATT&CK Updated: {created} new techniques", html)

View File

@@ -158,6 +158,40 @@ def cleanup_old_notifications(db: Session, days: int = 90) -> int:
# ---------------------------------------------------------------------------
def notify_role_with_email(
db: Session,
*,
role: str,
type: str,
title: str,
message: str,
entity_type: str,
entity_id: uuid.UUID,
email_fn=None, # callable(user_email) -> bool, optional
) -> None:
"""Send in-app notifications + optional email to all active users with a role."""
users = (
db.query(User)
.filter(User.role == role, User.is_active == True) # noqa: E712
.all()
)
for user in users:
create_notification(
db,
user_id=user.id,
type=type,
title=title,
message=message,
entity_type=entity_type,
entity_id=entity_id,
)
if email_fn and user.email:
try:
email_fn(user.email)
except Exception:
pass # email failures never crash notification flow
def notify_test_state_change(db: Session, test, new_state: str) -> None:
"""Dispatch notifications based on a test's new state.

View 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()