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/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/ 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):