Files
boc/iom/risk/risk_alerts.py
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

404 lines
14 KiB
Python

"""
IOM Lager 8 — Risk Alert System
Alert-system för risktrösklar med notifieringar.
"""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import Enum
from typing import Callable, Dict, List, Optional, Set, Any
import threading
from risk_model import RiskResult, RiskDimension
class AlertSeverity(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class AlertStatus(str, Enum):
ACTIVE = "active"
ACKNOWLEDGED = "acknowledged"
RESOLVED = "resolved"
SILENCED = "silenced"
# Tröskelvärden för alert-nivåer
RISK_THRESHOLDS = {
AlertSeverity.CRITICAL: 9.0,
AlertSeverity.HIGH: 7.0,
AlertSeverity.MEDIUM: 5.0,
AlertSeverity.LOW: 3.0,
}
@dataclass
class AlertRule:
"""Regel för när en alert ska triggas."""
rule_id: str
name: str
description: str = ""
# Trösklar
min_total_risk: Optional[float] = None
min_risk_level: Optional[int] = None
dimension_thresholds: Dict[str, float] = field(default_factory=dict)
# Filter
domains: Optional[List[str]] = None
object_type_codes: Optional[List[str]] = None
goids: Optional[List[str]] = None
# Åtgärd
severity: AlertSeverity = AlertSeverity.MEDIUM
auto_acknowledge_after_minutes: Optional[int] = None
cooldown_minutes: int = 60
enabled: bool = True
@dataclass
class Alert:
"""En enskild alert."""
alert_id: str
rule_id: str
goid: str
severity: AlertSeverity
status: AlertStatus
message: str
risk_result: RiskResult
created_at: str
acknowledged_at: Optional[str] = None
acknowledged_by: Optional[str] = None
resolved_at: Optional[str] = None
resolved_by: Optional[str] = None
resolution_note: Optional[str] = None
auto_resolve_after: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
d = asdict(self)
d["risk_result"] = {
"goid": self.risk_result.goid,
"total_risk": self.risk_result.total_risk,
"risk_level": self.risk_result.risk_level,
"dimension_scores": self.risk_result.dimension_scores,
"alert_triggered": self.risk_result.alert_triggered,
"alert_level": self.risk_result.alert_level,
}
return d
NotifierCallback = Callable[[Alert], None]
class RiskAlertManager:
"""Central hanterare för risk-alerts."""
def __init__(self):
self._rules: Dict[str, AlertRule] = {}
self._alerts: Dict[str, Alert] = {}
self._goid_alerts: Dict[str, Set[str]] = {} # goid → alert_ids
self._notifiers: List[NotifierCallback] = []
self._lock = threading.RLock()
self._cooldowns: Dict[str, float] = {} # goid+rule_id → timestamp
# ─── Regelhantering ──────────────────────────────────────────────────────
def add_rule(self, rule: AlertRule) -> None:
with self._lock:
self._rules[rule.rule_id] = rule
def remove_rule(self, rule_id: str) -> bool:
with self._lock:
return self._rules.pop(rule_id, None) is not None
def get_rule(self, rule_id: str) -> Optional[AlertRule]:
with self._lock:
return self._rules.get(rule_id)
def list_rules(self) -> List[AlertRule]:
with self._lock:
return list(self._rules.values())
def enable_rule(self, rule_id: str) -> bool:
with self._lock:
if rule := self._rules.get(rule_id):
rule.enabled = True
return True
return False
def disable_rule(self, rule_id: str) -> bool:
with self._lock:
if rule := self._rules.get(rule_id):
rule.enabled = False
return True
return False
# ─── Notifieringar ───────────────────────────────────────────────────────
def register_notifier(self, callback: NotifierCallback) -> None:
with self._lock:
self._notifiers.append(callback)
def unregister_notifier(self, callback: NotifierCallback) -> None:
with self._lock:
if callback in self._notifiers:
self._notifiers.remove(callback)
def _notify(self, alert: Alert) -> None:
for notifier in list(self._notifiers):
try:
notifier(alert)
except Exception:
pass # Isolera fel i notifierare
# ─── Alert-hantering ─────────────────────────────────────────────────────
def evaluate(self, risk_result: RiskResult) -> List[Alert]:
"""Utvärdera ett riskresultat mot alla regler och skapa alerts."""
triggered: List[Alert] = []
with self._lock:
for rule in self._rules.values():
if not rule.enabled:
continue
if not self._rule_matches(rule, risk_result):
continue
cooldown_key = f"{risk_result.goid}:{rule.rule_id}"
now = time.time()
last_triggered = self._cooldowns.get(cooldown_key, 0)
cooldown_seconds = rule.cooldown_minutes * 60
if now - last_triggered < cooldown_seconds:
continue
self._cooldowns[cooldown_key] = now
alert = self._create_alert(rule, risk_result)
self._alerts[alert.alert_id] = alert
self._goid_alerts.setdefault(risk_result.goid, set()).add(alert.alert_id)
triggered.append(alert)
self._notify(alert)
return triggered
def _rule_matches(self, rule: AlertRule, result: RiskResult) -> bool:
# Total risk-tröskel
if rule.min_total_risk is not None and result.total_risk < rule.min_total_risk:
return False
# Risknivå-tröskel
if rule.min_risk_level is not None and result.risk_level < rule.min_risk_level:
return False
# Dimensions-trösklar
for dim, threshold in rule.dimension_thresholds.items():
if result.dimension_scores.get(dim, 0) < threshold:
return False
# Domän-filter
if rule.domains is not None:
domain = result.goid.split("-")[0] if result.goid else None
if domain not in rule.domains:
return False
# GOID-filter
if rule.goids is not None and result.goid not in rule.goids:
return False
# Objekttyp-filter (prefix-match)
if rule.object_type_codes is not None:
matched = False
for code in rule.object_type_codes:
# Förenklad: antar att GOID börjar med typkoden
if result.goid.startswith(code):
matched = True
break
if not matched:
return False
return True
def _create_alert(self, rule: AlertRule, result: RiskResult) -> Alert:
now = datetime.now(timezone.utc).isoformat()
alert_id = f"ALERT-{result.goid}-{rule.rule_id}-{int(time.time())}"
message = self._build_alert_message(rule, result)
auto_resolve = None
if rule.auto_acknowledge_after_minutes:
auto_resolve_dt = datetime.now(timezone.utc)
# Sätt auto-resolve-tid
from datetime import timedelta
auto_resolve = (auto_resolve_dt + timedelta(minutes=rule.auto_acknowledge_after_minutes)).isoformat()
return Alert(
alert_id=alert_id,
rule_id=rule.rule_id,
goid=result.goid,
severity=rule.severity,
status=AlertStatus.ACTIVE,
message=message,
risk_result=result,
created_at=now,
auto_resolve_after=auto_resolve,
)
def _build_alert_message(self, rule: AlertRule, result: RiskResult) -> str:
parts = [
f"Risk alert för {result.goid}",
f"Regel: {rule.name}",
f"Total risk: {result.total_risk}/10 (nivå {result.risk_level})",
]
if result.alert_level:
parts.append(f"Alert-nivå: {result.alert_level}")
# Lista höga dimensioner
high_dims = [
d for d, v in result.dimension_scores.items() if v >= 7.0
]
if high_dims:
parts.append(f"Högrisk-dimensioner: {', '.join(high_dims)}")
return " | ".join(parts)
def acknowledge_alert(
self, alert_id: str, user: str, note: Optional[str] = None
) -> Optional[Alert]:
with self._lock:
if alert := self._alerts.get(alert_id):
if alert.status == AlertStatus.ACTIVE:
alert.status = AlertStatus.ACKNOWLEDGED
alert.acknowledged_at = datetime.now(timezone.utc).isoformat()
alert.acknowledged_by = user
if note:
alert.resolution_note = note
return alert
return None
def resolve_alert(
self, alert_id: str, user: str, note: Optional[str] = None
) -> Optional[Alert]:
with self._lock:
if alert := self._alerts.get(alert_id):
alert.status = AlertStatus.RESOLVED
alert.resolved_at = datetime.now(timezone.utc).isoformat()
alert.resolved_by = user
if note:
alert.resolution_note = note
return alert
return None
def silence_alert(self, alert_id: str, duration_minutes: int = 1440) -> Optional[Alert]:
with self._lock:
if alert := self._alerts.get(alert_id):
alert.status = AlertStatus.SILENCED
return alert
return None
def get_alert(self, alert_id: str) -> Optional[Alert]:
with self._lock:
return self._alerts.get(alert_id)
def list_alerts(
self,
goid: Optional[str] = None,
status: Optional[AlertStatus] = None,
severity: Optional[AlertSeverity] = None,
) -> List[Alert]:
with self._lock:
alerts = list(self._alerts.values())
if goid:
alerts = [a for a in alerts if a.goid == goid]
if status:
alerts = [a for a in alerts if a.status == status]
if severity:
alerts = [a for a in alerts if a.severity == severity]
return sorted(alerts, key=lambda a: a.created_at, reverse=True)
def get_active_alerts_for_goid(self, goid: str) -> List[Alert]:
with self._lock:
alert_ids = self._goid_alerts.get(goid, set())
alerts = [self._alerts[aid] for aid in alert_ids if aid in self._alerts]
return [a for a in alerts if a.status == AlertStatus.ACTIVE]
def get_alert_summary(self) -> Dict[str, Any]:
with self._lock:
total = len(self._alerts)
by_status: Dict[str, int] = {}
by_severity: Dict[str, int] = {}
for alert in self._alerts.values():
by_status[alert.status.value] = by_status.get(alert.status.value, 0) + 1
by_severity[alert.severity.value] = by_severity.get(alert.severity.value, 0) + 1
return {
"total_alerts": total,
"by_status": by_status,
"by_severity": by_severity,
"active_rules": len([r for r in self._rules.values() if r.enabled]),
}
def clear_old_alerts(self, max_age_hours: int = 168) -> int:
"""Rensa lösta alerts äldre än max_age_hours."""
cutoff = time.time() - (max_age_hours * 3600)
removed = 0
with self._lock:
to_remove = [
aid for aid, alert in self._alerts.items()
if alert.status in (AlertStatus.RESOLVED, AlertStatus.SILENCED)
and self._parse_ts(alert.created_at) < cutoff
]
for aid in to_remove:
alert = self._alerts.pop(aid)
self._goid_alerts.get(alert.goid, set()).discard(aid)
removed += 1
return removed
@staticmethod
def _parse_ts(ts: str) -> float:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
return dt.timestamp()
except Exception:
return 0.0
# ─── Färdiga notifierare ─────────────────────────────────────────────────────
class ConsoleNotifier:
"""Skriver alerts till stdout."""
def __call__(self, alert: Alert) -> None:
print(f"\n🚨 [{alert.severity.upper()}] {alert.message}")
print(f" Alert ID: {alert.alert_id}")
print(f" Skapad: {alert.created_at}\n")
class WebhookNotifier:
"""Skickar alerts via HTTP POST."""
def __init__(self, webhook_url: str, headers: Optional[Dict[str, str]] = None):
self.webhook_url = webhook_url
self.headers = headers or {"Content-Type": "application/json"}
def __call__(self, alert: Alert) -> None:
import urllib.request
payload = json.dumps(alert.to_dict(), default=str).encode("utf-8")
req = urllib.request.Request(
self.webhook_url,
data=payload,
headers=self.headers,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
pass
except Exception as exc:
print(f"WebhookNotifier failed: {exc}")
# ─── Global instans ──────────────────────────────────────────────────────────
_alert_manager: Optional[RiskAlertManager] = None
def get_alert_manager() -> RiskAlertManager:
global _alert_manager
if _alert_manager is None:
_alert_manager = RiskAlertManager()
return _alert_manager
def reset_alert_manager() -> None:
global _alert_manager
_alert_manager = None