189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
"""Persistence helpers for alert history."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.alert import AlertConfigurationAudit, AlertHistory
|
|
|
|
|
|
def _json_dumps(value: Dict[str, Any]) -> str:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
|
|
|
|
def alert_fingerprint(alert: Dict[str, Any]) -> str:
|
|
"""Return a stable fingerprint for one alert signal."""
|
|
identity = {
|
|
"rule": alert.get("rule"),
|
|
"domain": alert.get("domain"),
|
|
"source_ip": alert.get("source_ip"),
|
|
"threshold": alert.get("threshold"),
|
|
}
|
|
return hashlib.sha256(_json_dumps(identity).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _row_to_dict(row: AlertHistory) -> Dict[str, Any]:
|
|
payload = {}
|
|
if row.payload:
|
|
try:
|
|
payload = json.loads(row.payload)
|
|
except json.JSONDecodeError:
|
|
payload = {}
|
|
return {
|
|
"id": row.id,
|
|
"fingerprint": row.fingerprint,
|
|
"rule": row.rule,
|
|
"severity": row.severity,
|
|
"domain": row.domain,
|
|
"title": row.title,
|
|
"detail": row.detail,
|
|
"payload": payload,
|
|
"observed_count": row.observed_count,
|
|
"is_active": row.is_active,
|
|
"first_seen_at": row.first_seen_at.isoformat() if row.first_seen_at else None,
|
|
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
|
|
"resolved_at": row.resolved_at.isoformat() if row.resolved_at else None,
|
|
}
|
|
|
|
|
|
def record_alert_evaluation(
|
|
db: Session,
|
|
alerts: List[Dict[str, Any]],
|
|
*,
|
|
observed_at: Optional[datetime] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Upsert current alerts and resolve active alerts that are no longer present."""
|
|
timestamp = observed_at or datetime.utcnow()
|
|
fingerprints = {alert_fingerprint(alert): alert for alert in alerts}
|
|
|
|
if fingerprints:
|
|
existing_rows = (
|
|
db.query(AlertHistory)
|
|
.filter(AlertHistory.fingerprint.in_(list(fingerprints.keys())))
|
|
.all()
|
|
)
|
|
else:
|
|
existing_rows = []
|
|
existing_by_fingerprint = {row.fingerprint: row for row in existing_rows}
|
|
|
|
for fingerprint, alert in fingerprints.items():
|
|
row = existing_by_fingerprint.get(fingerprint)
|
|
if row is None:
|
|
row = AlertHistory(
|
|
fingerprint=fingerprint,
|
|
rule=str(alert.get("rule") or "unknown"),
|
|
severity=str(alert.get("severity") or "warning"),
|
|
domain=alert.get("domain"),
|
|
title=str(alert.get("title") or "DMARC alert"),
|
|
detail=str(alert.get("detail") or ""),
|
|
payload=_json_dumps(alert),
|
|
observed_count=1,
|
|
is_active=True,
|
|
first_seen_at=timestamp,
|
|
last_seen_at=timestamp,
|
|
)
|
|
db.add(row)
|
|
continue
|
|
|
|
row.rule = str(alert.get("rule") or row.rule)
|
|
row.severity = str(alert.get("severity") or row.severity)
|
|
row.domain = alert.get("domain")
|
|
row.title = str(alert.get("title") or row.title)
|
|
row.detail = str(alert.get("detail") or row.detail)
|
|
row.payload = _json_dumps(alert)
|
|
row.observed_count = int(row.observed_count or 0) + 1
|
|
row.is_active = True
|
|
row.last_seen_at = timestamp
|
|
row.resolved_at = None
|
|
|
|
active_rows = db.query(AlertHistory).filter(AlertHistory.is_active == True).all() # noqa: E712
|
|
for row in active_rows:
|
|
if row.fingerprint not in fingerprints:
|
|
row.is_active = False
|
|
row.resolved_at = timestamp
|
|
|
|
db.commit()
|
|
return list_alert_history(db, limit=max(50, len(alerts)))
|
|
|
|
|
|
def list_alert_history(
|
|
db: Session,
|
|
*,
|
|
active: Optional[bool] = None,
|
|
limit: int = 50,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Return alert history rows ordered by most recent observation."""
|
|
query = db.query(AlertHistory)
|
|
if active is not None:
|
|
query = query.filter(AlertHistory.is_active == active)
|
|
rows = (
|
|
query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all()
|
|
)
|
|
return [_row_to_dict(row) for row in rows]
|
|
|
|
|
|
def _actor_from_auth(auth_context: Optional[Dict[str, Any]]) -> Dict[str, Optional[str]]:
|
|
auth_context = auth_context or {}
|
|
user_id = auth_context.get("user_id")
|
|
if user_id is not None:
|
|
changed_by = str(user_id)
|
|
elif auth_context.get("payload", {}).get("sub"):
|
|
changed_by = str(auth_context["payload"]["sub"])
|
|
else:
|
|
changed_by = str(auth_context.get("auth_type") or "unknown")
|
|
return {
|
|
"changed_by": changed_by,
|
|
"auth_type": str(auth_context.get("auth_type") or "unknown"),
|
|
}
|
|
|
|
|
|
def record_alert_config_change(
|
|
db: Session,
|
|
*,
|
|
key: str,
|
|
old_value: Optional[str],
|
|
new_value: Optional[str],
|
|
auth_context: Optional[Dict[str, Any]] = None,
|
|
changed_at: Optional[datetime] = None,
|
|
) -> None:
|
|
"""Record one sanitized alert/notification setting change."""
|
|
actor = _actor_from_auth(auth_context)
|
|
db.add(
|
|
AlertConfigurationAudit(
|
|
key=key,
|
|
old_value=old_value,
|
|
new_value=new_value,
|
|
changed_by=actor["changed_by"],
|
|
auth_type=actor["auth_type"],
|
|
changed_at=changed_at or datetime.utcnow(),
|
|
)
|
|
)
|
|
|
|
|
|
def _config_audit_row_to_dict(row: AlertConfigurationAudit) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"key": row.key,
|
|
"old_value": row.old_value,
|
|
"new_value": row.new_value,
|
|
"changed_by": row.changed_by,
|
|
"auth_type": row.auth_type,
|
|
"changed_at": row.changed_at.isoformat() if row.changed_at else None,
|
|
}
|
|
|
|
|
|
def list_alert_config_audit(db: Session, *, limit: int = 50) -> List[Dict[str, Any]]:
|
|
"""Return recent alert/notification configuration changes."""
|
|
rows = (
|
|
db.query(AlertConfigurationAudit)
|
|
.order_by(AlertConfigurationAudit.changed_at.desc(), AlertConfigurationAudit.id.desc())
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return [_config_audit_row_to_dict(row) for row in rows]
|