feat: add notification alert rules

This commit is contained in:
Christian Krakau-Louis
2026-05-22 22:55:49 +02:00
parent 9d0375569b
commit 05b687b525
9 changed files with 703 additions and 10 deletions
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.setting import Setting
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
from app.services.notifications import send_notification
router = APIRouter()
@@ -127,6 +128,55 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.alert_new_sources_enabled",
"value": "true",
"description": "Alert when a new sending source appears in recent DMARC reports",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_compliance_drop_enabled",
"value": "true",
"description": "Alert when DMARC compliance drops by the configured percentage points",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_compliance_drop_points",
"value": "10",
"description": "Minimum compliance-rate drop, in percentage points, before alerting",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.alert_failure_threshold_enabled",
"value": "true",
"description": "Alert when DMARC failures exceed the configured daily threshold",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_failure_threshold_count",
"value": "100",
"description": "Minimum failed message count in the last day before alerting",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.alert_missing_reports_enabled",
"value": "true",
"description": "Alert when a monitored domain has not received recent DMARC reports",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.alert_missing_reports_days",
"value": "2",
"description": "Number of days without DMARC reports before alerting",
"value_type": "integer",
"category": "notifications",
},
]
# Keys whose values should be redacted in GET responses (treated as secrets)
@@ -216,6 +266,19 @@ class NotificationTestResponse(BaseModel):
error: Optional[str] = None
class AlertRulesResponse(BaseModel):
"""Current alert-rule evaluation response."""
alerts: List[Dict[str, Any]]
class AlertNotificationResponse(BaseModel):
"""Alert-rule evaluation plus notification delivery status."""
alerts: List[Dict[str, Any]]
notification: Dict[str, Any]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -261,6 +324,33 @@ async def test_notification_settings(
return result.to_dict()
@router.get("/notifications/alerts", response_model=AlertRulesResponse)
async def evaluate_notification_alerts(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> AlertRulesResponse:
"""Evaluate enabled notification alert rules against current DMARC data."""
_seed_defaults(db)
return {"alerts": evaluate_alert_rules(db)}
@router.post("/notifications/alerts/send", response_model=AlertNotificationResponse)
async def send_notification_alerts(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> AlertNotificationResponse:
"""Evaluate current alert rules and send a notification summary when needed."""
_seed_defaults(db)
result = send_current_alerts(db)
notification = result["notification"]
if result["alerts"] and not notification.get("success"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result,
)
return result
@router.get("/{key:path}", response_model=SettingResponse)
async def get_setting(
key: str,
+256
View File
@@ -0,0 +1,256 @@
"""Alert rule evaluation for DMARC report data."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import case, func
from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
from app.services.notifications import NotificationResult, send_notification
def _truthy(value: Optional[str], default: bool = True) -> bool:
if value in (None, ""):
return default
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _int_setting(value: Optional[str], default: int) -> int:
try:
return int(value or default)
except (TypeError, ValueError):
return default
def _settings(db: Session) -> Dict[str, Optional[str]]:
rows = db.query(Setting).filter(Setting.category == "notifications").all()
return {row.key: row.value for row in rows}
def _days_ago_ts(days: int) -> int:
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
def _new_source_alerts(db: Session, window_days: int = 7) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(window_days)
previous_sources = {
(row.domain, row.source_ip)
for row in (
db.query(Domain.name.label("domain"), ReportRecord.source_ip.label("source_ip"))
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date < cutoff_ts)
.distinct()
.all()
)
}
current_sources = (
db.query(
Domain.name.label("domain"),
ReportRecord.source_ip.label("source_ip"),
func.sum(ReportRecord.count).label("message_count"),
)
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date >= cutoff_ts)
.group_by(Domain.name, ReportRecord.source_ip)
.order_by(func.sum(ReportRecord.count).desc())
.all()
)
alerts = []
for row in current_sources:
if (row.domain, row.source_ip) in previous_sources:
continue
count = int(row.message_count or 0)
alerts.append(
{
"rule": "new_sender_source",
"severity": "warning",
"domain": row.domain,
"source_ip": row.source_ip,
"message_count": count,
"title": "New sending source",
"detail": f"{row.source_ip} first appeared for {row.domain} with {count} messages.",
}
)
return alerts
def _failure_threshold_alerts(
db: Session, threshold: int, window_days: int = 1
) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(window_days)
rows = (
db.query(
Domain.name.label("domain"),
func.sum(ReportRecord.count).label("failed_messages"),
)
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date >= cutoff_ts)
.filter(func.coalesce(ReportRecord.dkim, "fail") != "pass")
.filter(func.coalesce(ReportRecord.spf, "fail") != "pass")
.group_by(Domain.name)
.having(func.sum(ReportRecord.count) >= threshold)
.all()
)
return [
{
"rule": "dmarc_failures_above_threshold",
"severity": "error",
"domain": row.domain,
"failed_messages": int(row.failed_messages or 0),
"threshold": threshold,
"title": "DMARC failures above threshold",
"detail": (
f"{row.domain} had {int(row.failed_messages or 0)} DMARC failures "
f"in the last {window_days} day(s)."
),
}
for row in rows
]
def _missing_report_alerts(db: Session, missing_days: int) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(missing_days)
latest_rows = (
db.query(
Domain.name.label("domain"),
func.max(DMARCReport.end_date).label("last_report_ts"),
)
.outerjoin(DMARCReport, DMARCReport.domain_id == Domain.id)
.group_by(Domain.id, Domain.name)
.all()
)
alerts = []
for row in latest_rows:
last_report_ts = int(row.last_report_ts or 0)
if last_report_ts >= cutoff_ts:
continue
alerts.append(
{
"rule": "missing_reports",
"severity": "warning",
"domain": row.domain,
"missing_days": missing_days,
"last_report_at": (
datetime.fromtimestamp(last_report_ts, tz=timezone.utc).isoformat()
if last_report_ts
else None
),
"title": "Missing DMARC reports",
"detail": f"{row.domain} has no DMARC report in the last {missing_days} day(s).",
}
)
return alerts
def _compliance_drop_alerts(
db: Session, drop_points: int, window_days: int = 2
) -> List[Dict[str, Any]]:
cutoff_ts = _days_ago_ts(window_days)
rows = (
db.query(
Domain.name.label("domain"),
DMARCReport.begin_date.label("begin_date"),
func.sum(ReportRecord.count).label("total"),
func.sum(
case(
(
(ReportRecord.dkim == "pass") | (ReportRecord.spf == "pass"),
ReportRecord.count,
),
else_=0,
)
).label("passed"),
)
.join(DMARCReport, DMARCReport.domain_id == Domain.id)
.join(ReportRecord, ReportRecord.report_id == DMARCReport.id)
.filter(DMARCReport.begin_date >= cutoff_ts)
.group_by(Domain.name, DMARCReport.begin_date)
.order_by(Domain.name, DMARCReport.begin_date)
.all()
)
by_domain: Dict[str, List[Dict[str, float]]] = {}
for row in rows:
total = int(row.total or 0)
passed = int(row.passed or 0)
rate = round((passed / total) * 100, 1) if total else 0.0
by_domain.setdefault(row.domain, []).append({"date": row.begin_date, "rate": rate})
alerts = []
for domain, points in by_domain.items():
if len(points) < 2:
continue
previous = points[-2]
current = points[-1]
drop = round(previous["rate"] - current["rate"], 1)
if drop < drop_points:
continue
alerts.append(
{
"rule": "compliance_drop",
"severity": "error" if drop >= 25 else "warning",
"domain": domain,
"previous_rate": previous["rate"],
"current_rate": current["rate"],
"drop": drop,
"threshold": drop_points,
"title": "Compliance dropped",
"detail": f"{domain} compliance fell by {drop} percentage points.",
}
)
return alerts
def evaluate_alert_rules(db: Session) -> List[Dict[str, Any]]:
"""Evaluate all enabled alert rules against persisted DMARC data."""
settings = _settings(db)
alerts: List[Dict[str, Any]] = []
if _truthy(settings.get("notifications.alert_new_sources_enabled")):
alerts.extend(_new_source_alerts(db))
if _truthy(settings.get("notifications.alert_failure_threshold_enabled")):
threshold = _int_setting(settings.get("notifications.alert_failure_threshold_count"), 100)
alerts.extend(_failure_threshold_alerts(db, threshold))
if _truthy(settings.get("notifications.alert_missing_reports_enabled")):
missing_days = _int_setting(settings.get("notifications.alert_missing_reports_days"), 2)
alerts.extend(_missing_report_alerts(db, missing_days))
if _truthy(settings.get("notifications.alert_compliance_drop_enabled")):
drop_points = _int_setting(settings.get("notifications.alert_compliance_drop_points"), 10)
alerts.extend(_compliance_drop_alerts(db, drop_points))
return alerts
def send_current_alerts(db: Session) -> Dict[str, Any]:
"""Evaluate current alert rules and send one summary notification when needed."""
alerts = evaluate_alert_rules(db)
if not alerts:
return {
"alerts": [],
"notification": NotificationResult(
success=True, skipped=True, message="No alerts."
).to_dict(),
}
lines = [f"{alert['title']}: {alert['detail']}" for alert in alerts[:10]]
if len(alerts) > 10:
lines.append(f"...and {len(alerts) - 10} more alert(s).")
result = send_notification(
db,
title=f"DMARQ alert summary: {len(alerts)} alert(s)",
body="\n".join(lines),
)
return {"alerts": alerts, "notification": result.to_dict()}
+141
View File
@@ -235,7 +235,100 @@
</div>
</template>
<div class="border-t border-border pt-4 space-y-4">
<div>
<h3 class="text-sm font-semibold">Alert Rules</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_new_sources_enabled'] === 'true'"
@change="s['notifications.alert_new_sources_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">New sending sources</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_missing_reports_enabled'] === 'true'"
@change="s['notifications.alert_missing_reports_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Missing reports</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_failure_threshold_enabled'] === 'true'"
@change="s['notifications.alert_failure_threshold_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">High DMARC failures</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.alert_compliance_drop_enabled'] === 'true'"
@change="s['notifications.alert_compliance_drop_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Compliance drops</span>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Failure Threshold</span></label>
<input type="number" x-model.number="s['notifications.alert_failure_threshold_count']"
class="input input-bordered w-full" min="1" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Missing After Days</span></label>
<input type="number" x-model.number="s['notifications.alert_missing_reports_days']"
class="input input-bordered w-full" min="1" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Drop Points</span></label>
<input type="number" x-model.number="s['notifications.alert_compliance_drop_points']"
class="input input-bordered w-full" min="1" max="100" />
</div>
</div>
<template x-if="alertPreview !== null">
<div class="alert" :class="alertPreview > 0 ? 'alert-warning' : 'alert-success'">
<span x-text="alertPreview > 0 ? `${alertPreview} active alert${alertPreview === 1 ? '' : 's'} found.` : 'No active alerts found.'"></span>
</div>
</template>
</div>
<div class="flex flex-col sm:flex-row justify-end gap-2">
<button type="button" class="btn btn-outline btn-md" :disabled="saving || checkingAlerts" @click="checkAlerts()">
<template x-if="!checkingAlerts">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
</template>
<template x-if="checkingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Check Alerts
</button>
<button type="button" class="btn btn-outline btn-md" :disabled="saving || sendingAlerts" @click="sendAlertSummary()">
<template x-if="!sendingAlerts">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="M10.268 21a2 2 0 0 0 3.464 0"></path>
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"></path>
</svg>
</template>
<template x-if="sendingAlerts"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Send Alerts Now
</button>
<button type="button" class="btn btn-outline btn-md" :disabled="saving || testingNotification" @click="sendTestNotification()">
<template x-if="!testingNotification">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
@@ -292,6 +385,9 @@ function settingsApp() {
flashMsg: '',
flashOk: true,
testingNotification: false,
checkingAlerts: false,
sendingAlerts: false,
alertPreview: null,
showCfToken: false,
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
@@ -369,6 +465,51 @@ function settingsApp() {
}
},
async checkAlerts() {
this.checkingAlerts = true;
try {
await this.saveCategory('notifications');
const res = await fetch('/api/v1/settings/notifications/alerts', {
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showFlash('Alert check failed: ' + (data.detail || res.statusText), false);
} else {
this.alertPreview = (data.alerts || []).length;
this.showFlash(`${this.alertPreview} active alert${this.alertPreview === 1 ? '' : 's'} found.`, true);
}
} catch (err) {
this.showFlash('Error checking alerts: ' + err.message, false);
} finally {
this.checkingAlerts = false;
}
},
async sendAlertSummary() {
this.sendingAlerts = true;
try {
await this.saveCategory('notifications');
const res = await fetch('/api/v1/settings/notifications/alerts/send', {
method: 'POST',
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail || {};
const notification = detail.notification || {};
this.showFlash('Alert send failed: ' + (notification.message || data.detail || res.statusText), false);
} else {
this.alertPreview = (data.alerts || []).length;
this.showFlash(data.notification.skipped ? 'No active alerts to send.' : 'Alert summary sent.', true);
}
} catch (err) {
this.showFlash('Error sending alerts: ' + err.message, false);
} finally {
this.sendingAlerts = false;
}
},
showFlash(msg, ok) {
this.flashMsg = msg;
this.flashOk = ok;
+197
View File
@@ -2,10 +2,61 @@
Tests for the Settings model and /api/v1/settings endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
from app.models.setting import Setting
from app.services.notifications import NotificationResult
def _timestamp_days_ago(days: int) -> int:
return int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp())
def _add_domain(db_session: Session, name: str) -> Domain:
domain = Domain(name=name)
db_session.add(domain)
db_session.flush()
return domain
def _add_report_record(
db_session: Session,
domain: Domain,
*,
report_id: str,
days_ago: int,
source_ip: str,
count: int,
dkim: str = "pass",
spf: str = "pass",
) -> None:
begin_date = _timestamp_days_ago(days_ago)
report = DMARCReport(
domain_id=domain.id,
report_id=report_id,
org_name="receiver.example",
begin_date=begin_date,
end_date=begin_date + 3600,
policy="none",
)
db_session.add(report)
db_session.flush()
db_session.add(
ReportRecord(
report_id=report.id,
source_ip=source_ip,
count=count,
disposition="none",
dkim=dkim,
spf=spf,
header_from=domain.name,
)
)
class TestSettingModel:
@@ -51,6 +102,10 @@ class TestSettingsAPI:
assert "cloudflare.api_token" in keys
assert "notifications.apprise_enabled" in keys
assert "notifications.apprise_urls" in keys
assert "notifications.alert_new_sources_enabled" in keys
assert "notifications.alert_compliance_drop_points" in keys
assert "notifications.alert_failure_threshold_count" in keys
assert "notifications.alert_missing_reports_days" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
@@ -213,3 +268,145 @@ class TestSettingsAPI:
detail = res.json()["detail"]
assert detail["success"] is False
assert detail["message"] == "No notification targets are configured."
def test_notification_alert_rules_detect_new_source_and_failures(
self,
authed_client: TestClient,
db_session: Session,
):
"""GET /settings/notifications/alerts evaluates source and failure alerts."""
domain = _add_domain(db_session, "alerts.example")
_add_report_record(
db_session,
domain,
report_id="alerts-old-source",
days_ago=10,
source_ip="203.0.113.10",
count=12,
)
_add_report_record(
db_session,
domain,
report_id="alerts-new-source",
days_ago=0,
source_ip="203.0.113.20",
count=150,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
rules = {alert["rule"] for alert in alerts}
assert "new_sender_source" in rules
assert "dmarc_failures_above_threshold" in rules
new_source = next(alert for alert in alerts if alert["rule"] == "new_sender_source")
assert new_source["source_ip"] == "203.0.113.20"
def test_notification_alert_rules_detect_missing_reports(
self,
authed_client: TestClient,
db_session: Session,
):
"""Alert rules flag active monitored domains without recent reports."""
_add_domain(db_session, "missing.example")
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
assert any(
alert["rule"] == "missing_reports" and alert["domain"] == "missing.example"
for alert in alerts
)
def test_notification_alert_rules_detect_compliance_drop(
self,
authed_client: TestClient,
db_session: Session,
):
"""Alert rules compare recent compliance rates and flag large drops."""
domain = _add_domain(db_session, "drop.example")
_add_report_record(
db_session,
domain,
report_id="drop-known-source",
days_ago=10,
source_ip="203.0.113.30",
count=3,
)
_add_report_record(
db_session,
domain,
report_id="drop-passing-day",
days_ago=1,
source_ip="203.0.113.30",
count=10,
)
_add_report_record(
db_session,
domain,
report_id="drop-failing-day",
days_ago=0,
source_ip="203.0.113.30",
count=10,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.get("/api/v1/settings/notifications/alerts")
assert res.status_code == 200
alerts = res.json()["alerts"]
compliance_alert = next(alert for alert in alerts if alert["rule"] == "compliance_drop")
assert compliance_alert["domain"] == "drop.example"
assert compliance_alert["previous_rate"] == 100.0
assert compliance_alert["current_rate"] == 0.0
def test_notification_alert_send_uses_alert_summary(
self,
authed_client: TestClient,
db_session: Session,
monkeypatch,
):
"""POST /settings/notifications/alerts/send sends one alert summary."""
sent_messages = []
def fake_send_notification(
db, *, title, body, force=False
): # pylint: disable=unused-argument
sent_messages.append({"title": title, "body": body})
return NotificationResult(
success=True,
message="Notification sent.",
configured_targets=1,
)
monkeypatch.setattr("app.services.alert_rules.send_notification", fake_send_notification)
domain = _add_domain(db_session, "send.example")
_add_report_record(
db_session,
domain,
report_id="send-failing-day",
days_ago=0,
source_ip="203.0.113.40",
count=200,
dkim="fail",
spf="fail",
)
db_session.commit()
res = authed_client.post("/api/v1/settings/notifications/alerts/send")
assert res.status_code == 200
data = res.json()
assert data["notification"]["success"] is True
assert data["alerts"]
assert sent_messages[0]["title"].startswith("DMARQ alert summary")
assert "DMARC failures above threshold" in sent_messages[0]["body"]