From 120ac8dc3e9ca155426ef98418febcabb397c025 Mon Sep 17 00:00:00 2001 From: jovonni Date: Thu, 13 Aug 2026 01:00:56 -0400 Subject: [PATCH 1/2] feat(alerts): realtime alert notifications over SMTP + in-app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an SMTP email channel and in-app notifications for detections, wired through the existing rule engine, rule canvas, settings, and SDK — no new tables (reuses integration_settings + notifications). - notification_service: EmailService (config from integration_settings with SMTP_* env fallback, à la ChatService) + AlertNotifier (email + per-user in-app notification), best-effort so delivery never blocks alerting - rule_engine._fire_alert dispatches notifications when the alert node action is a notify action, threading the node's optional recipients - settings: register 'smtp' integration, mask the password, add a /test probe - rule canvas: 'fire alert + notify' action + optional recipients field - settings UI: Notifications section with an Email (SMTP) integration card - POST /api/v1/alerts to raise alerts directly (owns them under a system 'SDK Alerts' rule) with optional notify - SDK: openuba.send_alert(...) for models - dedicated test subsuite (28 unit tests, no container needed) Closes #25 --- core/api_routers/rules.py | 80 +++++- core/api_routers/settings.py | 40 ++- core/api_schemas/rules.py | 11 + core/services/notification_service.py | 218 ++++++++++++++++ core/services/rule_engine.py | 24 ++ .../test_api_routers/test_settings_smtp.py | 48 ++++ .../test_notification_service.py | 235 ++++++++++++++++++ .../test_services/test_rule_engine_notify.py | 77 ++++++ interface/src/components/rules/flow-nodes.tsx | 9 + .../src/components/settings/settings-tabs.tsx | 52 +++- sdk/src/openuba/__init__.py | 10 + sdk/src/openuba/client.py | 27 ++ 12 files changed, 823 insertions(+), 8 deletions(-) create mode 100644 core/services/notification_service.py create mode 100644 core/tests/test_api_routers/test_settings_smtp.py create mode 100644 core/tests/test_services/test_notification_service.py create mode 100644 core/tests/test_services/test_rule_engine_notify.py diff --git a/core/api_routers/rules.py b/core/api_routers/rules.py index 38de92c..94e8209 100644 --- a/core/api_routers/rules.py +++ b/core/api_routers/rules.py @@ -12,8 +12,30 @@ from core.db import get_db from core.db.models import Rule, Alert -from core.api_schemas.rules import RuleCreate, RuleUpdate, RuleResponse, AlertResponse +from core.api_schemas.rules import ( + RuleCreate, RuleUpdate, RuleResponse, AlertCreate, AlertResponse, +) from core.auth import require_permission +from core.services.notification_service import AlertNotifier + +SDK_ALERT_RULE_NAME = "SDK Alerts" + + +def _get_or_create_sdk_rule(db: Session) -> Rule: + '''owning rule for alerts raised directly via the SDK (alerts.rule_id is required)''' + rule = db.query(Rule).filter(Rule.name == SDK_ALERT_RULE_NAME).first() + if rule is None: + rule = Rule( + name=SDK_ALERT_RULE_NAME, + description="System rule for alerts raised directly through the OpenUBA SDK.", + rule_type="single-fire", + condition="sdk", + enabled=True, + severity="medium", + ) + db.add(rule) + db.flush() + return rule router = APIRouter() logger = logging.getLogger(__name__) @@ -119,6 +141,62 @@ async def delete_rule( logger.info(f"deleted rule: {rule_id}") +@router.post("/alerts", response_model=AlertResponse, status_code=201) +async def create_alert( + alert_data: AlertCreate, + db: Session = Depends(get_db), + current_user: dict = Depends(require_permission("rules", "write")) +): + ''' + raise an alert directly (e.g. from a model via the OpenUBA SDK). + + if `notify` is set, realtime notifications (SMTP email + in-app) are + dispatched best-effort, reusing the same delivery path as rule-fired alerts. + ''' + if alert_data.rule_id is not None: + rule = db.query(Rule).filter(Rule.id == alert_data.rule_id).first() + if rule is None: + raise HTTPException(status_code=404, detail="rule not found") + else: + rule = _get_or_create_sdk_rule(db) + + context = dict(alert_data.context or {}) + context.setdefault("source", "sdk") + + alert = Alert( + rule_id=rule.id, + severity=alert_data.severity, + message=alert_data.message, + entity_id=alert_data.entity_id, + entity_type=alert_data.entity_type or "user", + alert_context=context, + acknowledged=False, + ) + db.add(alert) + db.flush() + + if alert_data.notify: + try: + AlertNotifier().notify( + db, + rule_name=rule.name, + severity=alert.severity, + message=alert.message, + entity_id=alert.entity_id or "unknown", + entity_type=alert.entity_type or "user", + alert_id=str(alert.id), + recipients=alert_data.recipients, + context=context, + ) + except Exception as e: + logger.error(f"alert notification dispatch failed: {e}") + + db.commit() + db.refresh(alert) + logger.info(f"alert created via api: rule={rule.name} severity={alert.severity}") + return alert + + @router.get("/alerts", response_model=List[AlertResponse]) async def list_alerts( severity: Optional[str] = Query(None, pattern="^(critical|high|medium|low)$"), diff --git a/core/api_routers/settings.py b/core/api_routers/settings.py index 9a58599..3f7db4d 100644 --- a/core/api_routers/settings.py +++ b/core/api_routers/settings.py @@ -17,7 +17,7 @@ router = APIRouter() logger = logging.getLogger(__name__) -VALID_INTEGRATION_TYPES = {"ollama", "openai", "claude", "gemini", "elasticsearch", "spark"} +VALID_INTEGRATION_TYPES = {"ollama", "openai", "claude", "gemini", "elasticsearch", "spark", "smtp"} class IntegrationConfigUpdate(BaseModel): @@ -183,6 +183,8 @@ async def test_integration( return await _test_elasticsearch(config) elif integration_type == "spark": return await _test_spark(config) + elif integration_type == "smtp": + return await _test_smtp(config) except Exception as e: logger.error(f"test {integration_type} failed: {e}") return {"status": "error", "message": str(e)} @@ -283,10 +285,42 @@ async def _test_spark(config: dict) -> dict: return {"status": "error", "message": f"HTTP {resp.status_code}"} +async def _test_smtp(config: dict) -> dict: + '''verify SMTP connectivity + auth by opening a session and issuing NOOP''' + import smtplib + import ssl + + host = config.get("host", "") + if not host: + return {"status": "error", "message": "host not configured"} + port = int(config.get("port") or 587) + use_ssl = bool(config.get("use_ssl", False)) + use_tls = bool(config.get("use_tls", True)) + username = config.get("username", "") + password = config.get("password", "") + + try: + if use_ssl: + server: smtplib.SMTP = smtplib.SMTP_SSL( + host, port, timeout=10, context=ssl.create_default_context() + ) + else: + server = smtplib.SMTP(host, port, timeout=10) + if use_tls: + server.starttls(context=ssl.create_default_context()) + with server: + if username and password: + server.login(username, password) + server.noop() + return {"status": "connected"} + except Exception as e: + return {"status": "error", "message": str(e)} + + def _mask_sensitive_fields(config: dict) -> dict: - '''mask api keys in config for safe display''' + '''mask api keys / passwords in config for safe display''' masked = dict(config) - for key in ("api_key",): + for key in ("api_key", "password"): if key in masked and masked[key]: val = str(masked[key]) if len(val) > 8: diff --git a/core/api_schemas/rules.py b/core/api_schemas/rules.py index c297a28..3bd6152 100644 --- a/core/api_schemas/rules.py +++ b/core/api_schemas/rules.py @@ -62,6 +62,17 @@ class Config: from_attributes = True +class AlertCreate(BaseModel): + severity: str = Field(default="medium", pattern="^(critical|high|medium|low)$") + message: str = Field(..., min_length=1) + entity_id: Optional[str] = None + entity_type: Optional[str] = "user" + rule_id: Optional[UUID] = None + context: Optional[dict] = None + notify: bool = False + recipients: Optional[Union[str, List[str]]] = None + + class AlertResponse(BaseModel): id: UUID rule_id: UUID diff --git a/core/services/notification_service.py b/core/services/notification_service.py new file mode 100644 index 0000000..63b805e --- /dev/null +++ b/core/services/notification_service.py @@ -0,0 +1,218 @@ +''' +Copyright 2019-Present The OpenUBA Platform Authors +notification service — realtime alert delivery over SMTP (email) and in-app. + +Config is read from the integration_settings table (integration_type="smtp"), +mirroring how ChatService loads LLM provider config, and falls back to +environment variables. Nothing here raises to the caller: delivery is +best-effort so a mail outage can never block alert creation. +''' + +import logging +import os +import smtplib +import ssl +from email.message import EmailMessage +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from core.db import get_db_context +from core.db.models import Notification + +logger = logging.getLogger(__name__) + +# actions (from the rule-canvas alert node) that should send notifications +NOTIFY_ACTIONS = {"notify", "fire_alert_and_notify", "notify_and_open_case"} + + +def _as_recipient_list(value: Any) -> List[str]: + '''normalize a recipients value (list or comma/space string) to a clean list''' + if not value: + return [] + if isinstance(value, str): + parts = value.replace(";", ",").replace(" ", ",").split(",") + elif isinstance(value, (list, tuple, set)): + parts = list(value) + else: + return [] + return [str(p).strip() for p in parts if str(p).strip()] + + +class EmailService: + ''' + minimal SMTP sender. loads config from integration_settings (type="smtp"), + falls back to SMTP_* env vars. send() never raises — returns True/False. + ''' + + def _load_smtp_config(self) -> Optional[Dict[str, Any]]: + '''return smtp config dict if enabled+configured, else None''' + config: Dict[str, Any] = {} + try: + with get_db_context() as db: + row = db.execute(text( + "SELECT config, enabled FROM integration_settings " + "WHERE integration_type = 'smtp'" + )).fetchone() + if row and row[1]: # enabled + config = dict(row[0]) if row[0] else {} + except Exception as e: + logger.warning(f"failed to load smtp config from db: {e}") + + # env fallback for any field not set in the db row + host = config.get("host") or os.environ.get("SMTP_HOST", "") + if not host: + return None + + def _flag(cfg_key: str, env_key: str, default: bool) -> bool: + if cfg_key in config: + return bool(config[cfg_key]) + env = os.environ.get(env_key) + if env is None: + return default + return env.strip().lower() in ("1", "true", "yes", "on") + + return { + "host": host, + "port": int(config.get("port") or os.environ.get("SMTP_PORT", 587)), + "username": config.get("username") or os.environ.get("SMTP_USERNAME", ""), + "password": config.get("password") or os.environ.get("SMTP_PASSWORD", ""), + "from_addr": ( + config.get("from_addr") + or config.get("from") + or os.environ.get("SMTP_FROM") + or config.get("username") + or os.environ.get("SMTP_USERNAME", "openuba@localhost") + ), + "use_tls": _flag("use_tls", "SMTP_USE_TLS", True), + "use_ssl": _flag("use_ssl", "SMTP_USE_SSL", False), + "default_recipients": _as_recipient_list( + config.get("default_recipients") + or os.environ.get("SMTP_DEFAULT_RECIPIENTS", "") + ), + } + + def is_configured(self) -> bool: + return self._load_smtp_config() is not None + + def default_recipients(self) -> List[str]: + cfg = self._load_smtp_config() + return cfg["default_recipients"] if cfg else [] + + def send( + self, + to: List[str], + subject: str, + body_text: str, + body_html: Optional[str] = None, + timeout: int = 10, + ) -> bool: + '''send an email. returns True on success, False otherwise (never raises).''' + recipients = _as_recipient_list(to) + if not recipients: + logger.debug("email send skipped: no recipients") + return False + + cfg = self._load_smtp_config() + if not cfg: + logger.debug("email send skipped: smtp not configured/enabled") + return False + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = cfg["from_addr"] + msg["To"] = ", ".join(recipients) + msg.set_content(body_text) + if body_html: + msg.add_alternative(body_html, subtype="html") + + try: + if cfg["use_ssl"]: + context = ssl.create_default_context() + server: smtplib.SMTP = smtplib.SMTP_SSL( + cfg["host"], cfg["port"], timeout=timeout, context=context + ) + else: + server = smtplib.SMTP(cfg["host"], cfg["port"], timeout=timeout) + if cfg["use_tls"]: + server.starttls(context=ssl.create_default_context()) + with server: + if cfg["username"] and cfg["password"]: + server.login(cfg["username"], cfg["password"]) + server.send_message(msg) + logger.info(f"alert email sent to {len(recipients)} recipient(s)") + return True + except Exception as e: + logger.error(f"failed to send alert email: {e}") + return False + + +class AlertNotifier: + ''' + orchestrates realtime delivery for a fired alert: SMTP email (to the rule's + recipients or the configured default list) plus an in-app Notification for + every active user. Best-effort — failures are logged, never raised. + ''' + + def __init__(self, email_service: Optional[EmailService] = None): + self.email = email_service or EmailService() + + @staticmethod + def should_notify(action: Optional[str]) -> bool: + return bool(action) and action in NOTIFY_ACTIONS + + def notify( + self, + db: Session, + *, + rule_name: str, + severity: str, + message: str, + entity_id: str, + entity_type: str, + alert_id: Optional[str] = None, + recipients: Optional[Any] = None, + context: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + '''deliver email + in-app notifications for one alert. returns a summary.''' + subject = f"[OpenUBA] {severity.upper()} alert: {rule_name}" + lines = [ + f"Rule: {rule_name}", + f"Severity: {severity}", + f"Entity: {entity_type}/{entity_id}", + f"Message: {message}", + ] + if context: + if context.get("risk_score") is not None: + lines.append(f"Risk score: {context.get('risk_score')}") + if context.get("anomaly_type"): + lines.append(f"Anomaly type: {context.get('anomaly_type')}") + body_text = "\n".join(lines) + + summary = {"email_sent": False, "in_app_created": 0} + + # 1) email delivery (rule recipients override the configured default list) + to = _as_recipient_list(recipients) or self.email.default_recipients() + if to: + summary["email_sent"] = self.email.send(to, subject, body_text) + + # 2) in-app notifications for every active user (reuses the notifications + # center already wired into the frontend + notifications router) + try: + user_rows = db.execute(text( + "SELECT id FROM users WHERE is_active = true" + )).fetchall() + for (user_id,) in user_rows: + db.add(Notification( + user_id=user_id, + title=subject, + message=message, + type="alert", + link="/alerts", + )) + summary["in_app_created"] = len(user_rows) + except Exception as e: + logger.error(f"failed to create in-app notifications: {e}") + + return summary diff --git a/core/services/rule_engine.py b/core/services/rule_engine.py index e4a8002..47d329f 100644 --- a/core/services/rule_engine.py +++ b/core/services/rule_engine.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from core.db.models import Alert, Rule +from core.services.notification_service import AlertNotifier logger = logging.getLogger(__name__) @@ -212,6 +213,7 @@ def _evaluate_rule_for_anomaly( action=alert_data.get("action", "fire_alert"), anomaly_data=anomaly_data, db=db, + recipients=alert_data.get("recipients"), ) if created: fired += 1 @@ -373,10 +375,14 @@ def _fire_alert( action: str, anomaly_data: Dict[str, Any], db: Session, + recipients: Any = None, ) -> bool: """ Create an alert record. Returns True if alert was created, False if deduplicated (same rule+entity+severity within 1 hour). + + When the alert node's action is a notify action, realtime + notifications (SMTP email + in-app) are dispatched best-effort. """ entity_id = str(anomaly_data.get("entity_id", "unknown")) entity_type = anomaly_data.get("entity_type", "user") @@ -433,4 +439,22 @@ def _fire_alert( f"alert fired: rule={rule.name} entity={entity_id} " f"severity={severity} action={action}" ) + + # realtime notification delivery (best-effort, never blocks alerting) + if AlertNotifier.should_notify(action): + try: + AlertNotifier().notify( + db, + rule_name=rule.name, + severity=severity, + message=message, + entity_id=entity_id, + entity_type=entity_type, + alert_id=str(alert.id), + recipients=recipients, + context=context, + ) + except Exception as e: + logger.error(f"alert notification dispatch failed: {e}") + return True diff --git a/core/tests/test_api_routers/test_settings_smtp.py b/core/tests/test_api_routers/test_settings_smtp.py new file mode 100644 index 0000000..95662f6 --- /dev/null +++ b/core/tests/test_api_routers/test_settings_smtp.py @@ -0,0 +1,48 @@ +''' +Copyright 2019-Present The OpenUBA Platform Authors +settings router SMTP tests (issue #25) + +covers the smtp integration wiring: whitelist registration, password masking +on read, and the connectivity test (smtplib mocked). No container needed. +''' + +from unittest.mock import patch + +import pytest + +from core.api_routers.settings import ( + VALID_INTEGRATION_TYPES, + _mask_sensitive_fields, + _test_smtp, +) + + +def test_smtp_is_whitelisted(): + assert "smtp" in VALID_INTEGRATION_TYPES + + +def test_password_is_masked(): + masked = _mask_sensitive_fields({"password": "supersecretvalue", "host": "smtp.x"}) + assert masked["password"] != "supersecretvalue" + assert masked["host"] == "smtp.x" # non-sensitive fields untouched + + +def test_api_key_still_masked(): + masked = _mask_sensitive_fields({"api_key": "sk-abcdefghijklmnop"}) + assert masked["api_key"] != "sk-abcdefghijklmnop" + + +@pytest.mark.asyncio +async def test_test_smtp_reports_error_without_host(): + result = await _test_smtp({}) + assert result["status"] == "error" + + +@pytest.mark.asyncio +async def test_test_smtp_connected_with_starttls(): + cfg = {"host": "smtp.example.com", "port": 587, "use_tls": True, + "username": "u", "password": "p"} + with patch("smtplib.SMTP") as smtp_cls: + result = await _test_smtp(cfg) + assert result["status"] == "connected" + smtp_cls.return_value.login.assert_called_once_with("u", "p") diff --git a/core/tests/test_services/test_notification_service.py b/core/tests/test_services/test_notification_service.py new file mode 100644 index 0000000..5c642d8 --- /dev/null +++ b/core/tests/test_services/test_notification_service.py @@ -0,0 +1,235 @@ +''' +Copyright 2019-Present The OpenUBA Platform Authors +notification service tests (realtime alerts — issue #25) + +fast unit tests: SMTP config loading, recipient normalization, email send +(smtplib mocked), notify-action gating, and end-to-end dispatch with a +mocked db + email service. No live SMTP server or database container needed. +''' + +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pytest + +from core.services import notification_service as ns +from core.services.notification_service import ( + AlertNotifier, + EmailService, + NOTIFY_ACTIONS, + _as_recipient_list, +) + + +# ─── recipient normalization ───────────────────────────────────────────────── + +def test_as_recipient_list_handles_all_shapes(): + assert _as_recipient_list(None) == [] + assert _as_recipient_list("") == [] + assert _as_recipient_list("a@x.com") == ["a@x.com"] + assert _as_recipient_list("a@x.com, b@x.com") == ["a@x.com", "b@x.com"] + assert _as_recipient_list("a@x.com; b@x.com c@x.com") == ["a@x.com", "b@x.com", "c@x.com"] + assert _as_recipient_list([" a@x.com ", "b@x.com"]) == ["a@x.com", "b@x.com"] + assert _as_recipient_list(123) == [] + + +# ─── config loading ────────────────────────────────────────────────────────── + +@contextmanager +def _raise_ctx(): + raise RuntimeError("no db in unit test") + yield # pragma: no cover + + +def test_load_smtp_config_env_fallback(monkeypatch): + '''when the db is unavailable, config comes from SMTP_* env vars''' + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_PORT", "2525") + monkeypatch.setenv("SMTP_USERNAME", "bot@example.com") + monkeypatch.setenv("SMTP_PASSWORD", "secret") + monkeypatch.setenv("SMTP_DEFAULT_RECIPIENTS", "soc@example.com, oncall@example.com") + monkeypatch.setenv("SMTP_USE_TLS", "true") + + with patch.object(ns, "get_db_context", _raise_ctx): + cfg = EmailService()._load_smtp_config() + + assert cfg is not None + assert cfg["host"] == "smtp.example.com" + assert cfg["port"] == 2525 + assert cfg["username"] == "bot@example.com" + assert cfg["use_tls"] is True + assert cfg["default_recipients"] == ["soc@example.com", "oncall@example.com"] + + +def test_load_smtp_config_returns_none_when_unconfigured(monkeypatch): + monkeypatch.delenv("SMTP_HOST", raising=False) + with patch.object(ns, "get_db_context", _raise_ctx): + assert EmailService()._load_smtp_config() is None + assert EmailService().is_configured() is False + + +def test_db_config_takes_precedence(monkeypatch): + '''an enabled integration_settings row is used over env''' + monkeypatch.delenv("SMTP_HOST", raising=False) + + fake_db = MagicMock() + fake_db.execute.return_value.fetchone.return_value = ( + {"host": "mail.internal", "port": 465, "use_ssl": True, + "default_recipients": ["team@internal"]}, + True, # enabled + ) + + @contextmanager + def fake_ctx(): + yield fake_db + + with patch.object(ns, "get_db_context", fake_ctx): + cfg = EmailService()._load_smtp_config() + + assert cfg["host"] == "mail.internal" + assert cfg["port"] == 465 + assert cfg["use_ssl"] is True + + +# ─── email sending (smtplib mocked) ────────────────────────────────────────── + +def test_send_returns_false_without_recipients(): + svc = EmailService() + with patch.object(EmailService, "_load_smtp_config", return_value={"host": "x"}): + assert svc.send([], "subj", "body") is False + + +def test_send_returns_false_when_not_configured(): + svc = EmailService() + with patch.object(EmailService, "_load_smtp_config", return_value=None): + assert svc.send(["a@x.com"], "subj", "body") is False + + +def test_send_uses_starttls_and_login(): + cfg = { + "host": "smtp.example.com", "port": 587, "username": "u", "password": "p", + "from_addr": "from@x.com", "use_tls": True, "use_ssl": False, + "default_recipients": [], + } + with patch.object(EmailService, "_load_smtp_config", return_value=cfg), \ + patch.object(ns.smtplib, "SMTP") as smtp_cls: + server = smtp_cls.return_value + ok = EmailService().send(["a@x.com"], "subj", "body text") + + assert ok is True + smtp_cls.assert_called_once() + server.starttls.assert_called_once() + server.login.assert_called_once_with("u", "p") + server.send_message.assert_called_once() + + +def test_send_uses_ssl_transport(): + cfg = { + "host": "smtp.example.com", "port": 465, "username": "", "password": "", + "from_addr": "from@x.com", "use_tls": False, "use_ssl": True, + "default_recipients": [], + } + with patch.object(EmailService, "_load_smtp_config", return_value=cfg), \ + patch.object(ns.smtplib, "SMTP_SSL") as smtp_ssl: + ok = EmailService().send(["a@x.com"], "subj", "body") + + assert ok is True + smtp_ssl.assert_called_once() + # no credentials → no login attempt + smtp_ssl.return_value.login.assert_not_called() + + +def test_send_swallows_errors(): + cfg = { + "host": "smtp.example.com", "port": 587, "username": "", "password": "", + "from_addr": "from@x.com", "use_tls": True, "use_ssl": False, + "default_recipients": [], + } + with patch.object(EmailService, "_load_smtp_config", return_value=cfg), \ + patch.object(ns.smtplib, "SMTP", side_effect=OSError("connection refused")): + assert EmailService().send(["a@x.com"], "subj", "body") is False + + +# ─── notify-action gating ──────────────────────────────────────────────────── + +@pytest.mark.parametrize("action,expected", [ + ("notify", True), + ("fire_alert_and_notify", True), + ("notify_and_open_case", True), + ("fire_alert", False), + ("open_case", False), + ("", False), + (None, False), +]) +def test_should_notify(action, expected): + assert AlertNotifier.should_notify(action) is expected + + +def test_notify_actions_membership(): + assert "notify" in NOTIFY_ACTIONS + assert "fire_alert" not in NOTIFY_ACTIONS + + +# ─── end-to-end dispatch (email + in-app), mocked ──────────────────────────── + +def _fake_db_with_users(n=3): + db = MagicMock() + db.execute.return_value.fetchall.return_value = [(f"user-{i}",) for i in range(n)] + return db + + +def test_notify_sends_email_to_rule_recipients_and_creates_in_app(): + email = MagicMock(spec=EmailService) + email.send.return_value = True + email.default_recipients.return_value = ["default@x.com"] + db = _fake_db_with_users(3) + + summary = AlertNotifier(email_service=email).notify( + db, + rule_name="Impossible travel", + severity="high", + message="user logged in from two continents", + entity_id="u123", + entity_type="user", + recipients="analyst@x.com", + context={"risk_score": 0.97, "anomaly_type": "geo"}, + ) + + # rule recipients override the default list (normalized to a list) + args, _ = email.send.call_args + assert args[0] == ["analyst@x.com"] + assert "Impossible travel" in args[1] # subject includes rule name + assert summary["email_sent"] is True + # one in-app notification per active user + assert summary["in_app_created"] == 3 + assert db.add.call_count == 3 + + +def test_notify_falls_back_to_default_recipients(): + email = MagicMock(spec=EmailService) + email.send.return_value = True + email.default_recipients.return_value = ["soc@x.com"] + db = _fake_db_with_users(1) + + AlertNotifier(email_service=email).notify( + db, rule_name="r", severity="low", message="m", + entity_id="e", entity_type="user", recipients=None, + ) + + args, _ = email.send.call_args + assert args[0] == ["soc@x.com"] + + +def test_notify_still_creates_in_app_when_no_email_recipients(): + email = MagicMock(spec=EmailService) + email.default_recipients.return_value = [] + db = _fake_db_with_users(2) + + summary = AlertNotifier(email_service=email).notify( + db, rule_name="r", severity="medium", message="m", + entity_id="e", entity_type="user", + ) + + email.send.assert_not_called() + assert summary["email_sent"] is False + assert summary["in_app_created"] == 2 diff --git a/core/tests/test_services/test_rule_engine_notify.py b/core/tests/test_services/test_rule_engine_notify.py new file mode 100644 index 0000000..a65f34f --- /dev/null +++ b/core/tests/test_services/test_rule_engine_notify.py @@ -0,0 +1,77 @@ +''' +Copyright 2019-Present The OpenUBA Platform Authors +rule engine → notification wiring tests (issue #25) + +verifies that a fired alert dispatches realtime notifications only when the +alert node's action is a notify action, and threads the node's recipients +through. Uses a mocked db + patched AlertNotifier (no container needed). +''' + +from unittest.mock import MagicMock, patch + +import pytest + +from core.services import rule_engine as re_mod +from core.services.rule_engine import RuleEngine + + +def _fake_db_no_dedup(): + '''db whose dedup COUNT(*) returns 0 so the alert is always created''' + db = MagicMock() + db.execute.return_value.scalar.return_value = 0 + return db + + +def _rule(): + rule = MagicMock() + rule.name = "Impossible travel" + rule.id = "11111111-1111-1111-1111-111111111111" + return rule + + +ANOMALY = {"entity_id": "u1", "entity_type": "user", "risk_score": 0.9} + + +def test_fire_alert_dispatches_on_notify_action(): + db = _fake_db_no_dedup() + with patch.object(re_mod, "AlertNotifier") as notifier_cls: + notifier_cls.should_notify.return_value = True + created = RuleEngine()._fire_alert( + rule=_rule(), severity="high", message="msg", + action="notify", anomaly_data=ANOMALY, db=db, + recipients="soc@x.com", + ) + + assert created is True + notifier_cls.return_value.notify.assert_called_once() + # recipients from the node are threaded through + _, kwargs = notifier_cls.return_value.notify.call_args + assert kwargs["recipients"] == "soc@x.com" + + +def test_fire_alert_skips_dispatch_on_plain_action(): + db = _fake_db_no_dedup() + with patch.object(re_mod, "AlertNotifier") as notifier_cls: + notifier_cls.should_notify.return_value = False + created = RuleEngine()._fire_alert( + rule=_rule(), severity="high", message="msg", + action="fire_alert", anomaly_data=ANOMALY, db=db, + ) + + assert created is True + notifier_cls.return_value.notify.assert_not_called() + + +def test_fire_alert_notification_failure_does_not_break_alerting(): + db = _fake_db_no_dedup() + with patch.object(re_mod, "AlertNotifier") as notifier_cls: + notifier_cls.should_notify.return_value = True + notifier_cls.return_value.notify.side_effect = RuntimeError("smtp down") + # must not raise — alert creation is the priority + created = RuleEngine()._fire_alert( + rule=_rule(), severity="high", message="msg", + action="notify", anomaly_data=ANOMALY, db=db, + ) + + assert created is True + db.add.assert_called_once() # the alert was still added diff --git a/interface/src/components/rules/flow-nodes.tsx b/interface/src/components/rules/flow-nodes.tsx index 8bf300a..d923360 100644 --- a/interface/src/components/rules/flow-nodes.tsx +++ b/interface/src/components/rules/flow-nodes.tsx @@ -250,8 +250,17 @@ export const AlertOutputNode = memo((props: NodeProps) => { open case fire alert + open case send notification + fire alert + notify + {['notify', 'fire_alert_and_notify', 'notify_and_open_case'].includes(d.action) && ( + d.onChange?.(id, 'recipients', e.target.value)} + /> + )} ) }) diff --git a/interface/src/components/settings/settings-tabs.tsx b/interface/src/components/settings/settings-tabs.tsx index f5e764e..c49c74c 100644 --- a/interface/src/components/settings/settings-tabs.tsx +++ b/interface/src/components/settings/settings-tabs.tsx @@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Badge } from "@/components/ui/badge" -import { Plus, Trash2, Edit2, X, Loader2, CheckCircle2, XCircle, Zap, Brain, Bot, Sparkles, Database, Cpu, Save, Shield } from "lucide-react" +import { Plus, Trash2, Edit2, X, Loader2, CheckCircle2, XCircle, Zap, Brain, Bot, Sparkles, Database, Cpu, Save, Shield, Mail } from "lucide-react" import { useAuth } from '@/lib/auth-provider' import { useUIStore } from '@/lib/state/ui-store' @@ -22,13 +22,13 @@ function getAuthHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {} } -type IntegrationType = 'ollama' | 'openai' | 'claude' | 'gemini' | 'elasticsearch' | 'spark' +type IntegrationType = 'ollama' | 'openai' | 'claude' | 'gemini' | 'elasticsearch' | 'spark' | 'smtp' interface IntegrationDef { type: IntegrationType name: string description: string - category: 'llm' | 'data' + category: 'llm' | 'data' | 'notifications' icon: React.ReactNode color: string fields: FieldDef[] @@ -142,6 +142,24 @@ const INTEGRATION_DEFS: IntegrationDef[] = [ }, ], }, + { + type: 'smtp', + name: 'Email (SMTP)', + description: 'Send realtime alert notifications over email', + category: 'notifications', + icon: , + color: 'text-rose-500 bg-rose-500/10', + fields: [ + { key: 'host', label: 'SMTP Host', type: 'text', placeholder: 'smtp.gmail.com', required: true }, + { key: 'port', label: 'Port', type: 'text', placeholder: '587' }, + { key: 'username', label: 'Username', type: 'text', placeholder: 'alerts@yourorg.com' }, + { key: 'password', label: 'Password', type: 'password', placeholder: '' }, + { key: 'from_addr', label: 'From Address', type: 'text', placeholder: 'openuba@yourorg.com' }, + { key: 'default_recipients', label: 'Default Recipients', type: 'text', placeholder: 'soc@yourorg.com, oncall@yourorg.com' }, + { key: 'use_tls', label: 'Use STARTTLS', type: 'toggle' }, + { key: 'use_ssl', label: 'Use SSL', type: 'toggle' }, + ], + }, ] interface IntegrationState { @@ -397,6 +415,7 @@ function IntegrationsPanel() { const llmDefs = INTEGRATION_DEFS.filter(d => d.category === 'llm') const dataDefs = INTEGRATION_DEFS.filter(d => d.category === 'data') + const notificationDefs = INTEGRATION_DEFS.filter(d => d.category === 'notifications') const activeDef = configPanel ? INTEGRATION_DEFS.find(d => d.type === configPanel) : null return ( @@ -404,7 +423,7 @@ function IntegrationsPanel() { Integrations - Configure LLM providers and external data services. + Configure LLM providers, external data services, and notification channels.
@@ -452,6 +471,31 @@ function IntegrationsPanel() { ))}
+ + {notificationDefs.length > 0 && ( +
+

Notifications

+
+ {notificationDefs.map(def => ( +
+
+
+ {def.icon} +
+
+

{def.name}

+

{def.description}

+
+
+
+ {statusBadge(def.type)} + +
+
+ ))} +
+
+ )}
diff --git a/sdk/src/openuba/__init__.py b/sdk/src/openuba/__init__.py index a6c0879..0c9528f 100644 --- a/sdk/src/openuba/__init__.py +++ b/sdk/src/openuba/__init__.py @@ -289,6 +289,15 @@ def list_rules(enabled=True): return _get_client().list_rules(enabled=enabled) +def send_alert(message, severity="medium", entity_id=None, entity_type="user", + rule_id=None, context=None, notify=False, recipients=None): + """Raise an alert from a model (set notify=True to also send email + in-app).""" + return _get_client().send_alert( + message, severity=severity, entity_id=entity_id, entity_type=entity_type, + rule_id=rule_id, context=context, notify=notify, recipients=recipients, + ) + + __all__ = [ # Core classes "OpenUBAClient", @@ -349,4 +358,5 @@ def list_rules(enabled=True): "get_entity_risk", "query_cases", "list_rules", + "send_alert", ] diff --git a/sdk/src/openuba/client.py b/sdk/src/openuba/client.py index 7fe14f3..1091cff 100644 --- a/sdk/src/openuba/client.py +++ b/sdk/src/openuba/client.py @@ -638,6 +638,33 @@ def list_rules(self, enabled=True): '''list detection rules''' return self._get("/api/v1/rules", params={"enabled": enabled}) + def send_alert(self, message, severity="medium", entity_id=None, + entity_type="user", rule_id=None, context=None, + notify=False, recipients=None): + ''' + raise an alert from a model. + + Set notify=True to also deliver realtime notifications (SMTP email + + in-app). `recipients` (list or comma-separated string) overrides the + SMTP default recipient list configured in Settings. Requires the + configured token to have rules:write permission. + ''' + payload = { + "message": message, + "severity": severity, + "entity_type": entity_type, + "notify": notify, + } + if entity_id is not None: + payload["entity_id"] = entity_id + if rule_id is not None: + payload["rule_id"] = rule_id + if context is not None: + payload["context"] = context + if recipients is not None: + payload["recipients"] = recipients + return self._post("/api/v1/alerts", payload) + # ─── Data Query Methods ───────────────────────────────────────── def query_spark(self, query, spark_master=None): From 2cfa22c5eb9c433efd1e2e300345596e953ac7a0 Mon Sep 17 00:00:00 2001 From: jovonni Date: Thu, 13 Aug 2026 01:12:04 -0400 Subject: [PATCH 2/2] ci(workspace): upgrade pip/setuptools/wheel before sdist installs The workspace image build failed generating pyspark metadata ('setuptools is not available in the build environment'). Upgrade build tooling right after the base image so isolated sdist builds resolve setuptools. Unblocks the workspace CI triggered by the sdk/ change in this branch. --- docker/workspace/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker/workspace/Dockerfile b/docker/workspace/Dockerfile index 4469ee7..30b44c8 100644 --- a/docker/workspace/Dockerfile +++ b/docker/workspace/Dockerfile @@ -2,6 +2,11 @@ FROM jupyter/scipy-notebook:python-3.11 USER root +# ensure modern build tooling so sdist-only deps (e.g. pyspark) can generate +# metadata in an isolated build env (fixes "setuptools is not available in the +# build environment" during the data-connector install below) +RUN pip install --no-cache-dir --upgrade pip setuptools wheel + # install openuba SDK COPY sdk/src/openuba /tmp/openuba-sdk/src/openuba COPY sdk/pyproject.toml /tmp/openuba-sdk/