From a30c9d9037661d5d888d3856614069a199473401 Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 3 Jun 2026 14:59:41 +0000 Subject: [PATCH] feat(infra): per-event configurable notification thresholds Adds a notification_settings table (migration 007) with one row per event type holding a configurable minimum severity threshold. Defaults are applied in code so upgrades are no-ops: - scan.complete: medium (approximates the prior any-finding rule) - scan.failed: info (always notify) - scanner.failed: info (always notify) The dispatcher now consults the threshold before persisting an in-app notification. Failure events are synthesized at critical severity so threshold up to and including critical fires; clean scans never notify regardless of threshold. Adds GET/PATCH /api/v1/settings/notifications (admin scope) and a matching /settings/notifications page in the dashboard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/securescan/api/scans.py | 124 ++++++-- backend/securescan/api/settings.py | 97 ++++++ backend/securescan/database.py | 105 +++++++ backend/securescan/main.py | 2 + .../007_add_notification_settings.py | 39 +++ backend/securescan/models.py | 38 +++ backend/tests/test_migrations.py | 27 +- backend/tests/test_notification_settings.py | 291 ++++++++++++++++++ .../src/app/settings/notifications/page.tsx | 209 +++++++++++++ frontend/src/components/sidebar.tsx | 1 + frontend/src/lib/api.ts | 58 ++++ 11 files changed, 965 insertions(+), 26 deletions(-) create mode 100644 backend/securescan/api/settings.py create mode 100644 backend/securescan/migrations/007_add_notification_settings.py create mode 100644 backend/tests/test_notification_settings.py create mode 100644 frontend/src/app/settings/notifications/page.tsx diff --git a/backend/securescan/api/scans.py b/backend/securescan/api/scans.py index ea8725e..0fc52b7 100644 --- a/backend/securescan/api/scans.py +++ b/backend/securescan/api/scans.py @@ -22,6 +22,7 @@ delete_scan_cascade, get_findings, get_findings_with_state, + get_notification_threshold, get_scan, get_scan_summary, get_scans, @@ -42,6 +43,7 @@ ScanRequest, ScanStatus, ScanSummary, + Severity, ) from ..reports import ReportGenerator from ..scanners import get_scanners_for_types @@ -145,23 +147,76 @@ def _log_scan_event(event: str, *, scan_id: str, **fields: Any) -> None: _NOTIF_SCANNER_ERROR_TRUNCATE = 100 +# Numeric ordering for the finding-severity ladder used by per-event +# notification thresholds (issue #6). The dispatcher emits a +# notification only when the event's effective severity is >= the +# configured threshold. +_SEVERITY_RANK: dict[Severity, int] = { + Severity.INFO: 0, + Severity.LOW: 1, + Severity.MEDIUM: 2, + Severity.HIGH: 3, + Severity.CRITICAL: 4, +} + + +def _severity_rank(value: Severity | str | None) -> int: + """Map a Severity enum / raw string to its rank, defaulting to info. + + Defensive against unexpected string values (e.g. a future severity + label) so a typo cannot accidentally suppress notifications: an + unknown level is treated as `info` (lowest rank). + """ + if value is None: + return _SEVERITY_RANK[Severity.INFO] + if isinstance(value, Severity): + return _SEVERITY_RANK[value] + try: + return _SEVERITY_RANK[Severity(value)] + except (ValueError, KeyError): + return _SEVERITY_RANK[Severity.INFO] + + +def _max_severity_from_summary(fields: dict[str, Any]) -> Severity | None: + """Return the highest finding severity present in a scan.complete event. + + Looks at an explicit ``max_severity`` field first (preferred -- + cheap to compute at publish time). Falls back to scanning the + standard summary count fields (``critical``/``high``/...). Returns + ``None`` when the event has no findings -- the caller treats that + as "do not notify" regardless of threshold, preserving the + pre-issue-6 "no bell on a clean scan" behavior. + """ + explicit = fields.get("max_severity") + if explicit: + try: + return Severity(explicit) if not isinstance(explicit, Severity) else explicit + except ValueError: + pass + for sev in (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO): + count = fields.get(sev.value) + if isinstance(count, int) and count > 0: + return sev + return None + + async def _create_notification_for_event(event: str, scan_id: str, fields: dict[str, Any]) -> None: """Persist a notification for the small subset of events that warrant one. - Filtering rules (deliberately conservative -- the bell should - surface signal, not be a duplicate of the SSE feed): - - * ``scan.complete`` — only when ``findings_count > 0``. Severity - is ``warning`` whenever findings were found, ``info`` otherwise. - Per spec we use the simple count-based rule rather than fetching - the scan summary to count critical/high (extra DB call per - event). When findings_count == 0 we don't notify at all, so - the ``info`` branch is structurally unreachable today; it's - kept for symmetry in case the filter is loosened later. - * ``scan.failed`` — always notify; severity ``error``. - * ``scanner.failed`` — always notify; severity ``warning``. + Per-event thresholds (issue #6, see + `database.NOTIFICATION_THRESHOLD_DEFAULTS` and the + `notification_settings` table) gate dispatch: + + * ``scan.complete`` - effective severity is the highest finding + severity in the scan summary (or `None` for a clean scan, which + always skips). Default threshold: ``medium``. + * ``scan.failed`` - effective severity is ``critical`` (always + passes any threshold up to and including ``critical``). Default + threshold: ``info`` (always notify). + * ``scanner.failed`` - same as ``scan.failed``. Default threshold: + ``info``. * Everything else (scan.start, scanner.start, scanner.complete, - scanner.skipped, scan.cancelled) — no notification. Those + scanner.skipped, scan.cancelled) -- no notification. Those events are loud on the SSE stream while the dashboard is open; persisting them all would drown the bell. @@ -174,15 +229,20 @@ async def _create_notification_for_event(event: str, scan_id: str, fields: dict[ if event == "scan.complete": findings_count = int(fields.get("findings_count", 0) or 0) if findings_count <= 0: + # A clean scan never buzzes the bell, regardless of + # threshold -- there is no "event severity" to test. + return + # Treat a missing max_severity as "critical" so legacy + # callers (e.g. older publish sites or unit tests that + # only pass `findings_count`) continue to fire under the + # default `medium` threshold. The hot path always + # provides max_severity. + event_sev = _max_severity_from_summary(fields) or Severity.CRITICAL + threshold = await get_notification_threshold("scan.complete") + if _severity_rank(event_sev) < _severity_rank(threshold): return target_path = fields.get("target") or fields.get("target_path") or "" - severity = ( - NotificationSeverity.WARNING if findings_count > 0 else NotificationSeverity.INFO - ) - # Build the body defensively: if the publishing site forgot - # to include a target field, fall back to " findings" - # rather than producing the dangling " findings on " - # string the dashboard ended up rendering pre-v0.11.5. + severity = NotificationSeverity.WARNING body = ( f"{findings_count} findings on {target_path}" if target_path @@ -198,6 +258,11 @@ async def _create_notification_for_event(event: str, scan_id: str, fields: dict[ return if event == "scan.failed": + threshold = await get_notification_threshold("scan.failed") + # Failure events are synthesized at "critical" so any + # threshold up to the top of the ladder still fires. + if _severity_rank(Severity.CRITICAL) < _severity_rank(threshold): + return error = str(fields.get("error", "") or "")[:_NOTIF_BODY_TRUNCATE] await insert_notification( type="scan.failed", @@ -209,6 +274,9 @@ async def _create_notification_for_event(event: str, scan_id: str, fields: dict[ return if event == "scanner.failed": + threshold = await get_notification_threshold("scanner.failed") + if _severity_rank(Severity.CRITICAL) < _severity_rank(threshold): + return scanner = fields.get("scanner", "scanner") error = str(fields.get("error", "") or "")[:_NOTIF_SCANNER_ERROR_TRUNCATE] await insert_notification( @@ -439,6 +507,22 @@ async def _run_one(scanner): duration_s=round(time.perf_counter() - scan_started_perf, 2), scanner_count=len(scanners_run), findings_count=summary.total_findings, + # Highest severity present in the run, used by the + # notification dispatcher to gate on the per-event + # threshold (issue #6). `None` for a clean scan. + max_severity=( + Severity.CRITICAL.value + if summary.critical + else Severity.HIGH.value + if summary.high + else Severity.MEDIUM.value + if summary.medium + else Severity.LOW.value + if summary.low + else Severity.INFO.value + if summary.info + else None + ), ) except asyncio.CancelledError: latest_scan = await get_scan(scan_id) diff --git a/backend/securescan/api/settings.py b/backend/securescan/api/settings.py new file mode 100644 index 0000000..b2eaf7b --- /dev/null +++ b/backend/securescan/api/settings.py @@ -0,0 +1,97 @@ +"""Per-event notification threshold settings (issue #6). + +Endpoints +--------- +* ``GET /api/settings/notifications`` - admin: list thresholds. + Always returns a row per known event type with the default applied + server-side when the underlying table has no row yet. +* ``PATCH /api/settings/notifications`` - admin: update one or more + thresholds. Partial updates are allowed; unspecified events keep + their current (or default) value. + +Both endpoints require the ``admin`` scope -- the threshold gates +operator-facing notifications and an attacker who could lower it to +"critical" would silence routine failures. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from ..auth import require_scope +from ..database import ( + get_notification_settings, + upsert_notification_threshold, +) +from ..models import ( + NotificationEventType, + NotificationSettings, + NotificationThresholdSetting, + Severity, +) + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +class NotificationThresholdUpdate(BaseModel): + """Single threshold update entry inside `NotificationSettingsPatch`. + + Pydantic validation rejects unknown event types and severities at + the boundary, so the handler can persist without re-checking. + """ + + event_type: NotificationEventType + min_severity: Severity + + +class NotificationSettingsPatch(BaseModel): + """PATCH body: a list of (event_type, min_severity) updates. + + Empty list is allowed (no-op) so the UI can submit a clean form + without special-casing the "nothing changed" path. + """ + + thresholds: list[NotificationThresholdUpdate] = Field(default_factory=list) + + +@router.get( + "/notifications", + response_model=NotificationSettings, + dependencies=[Depends(require_scope("admin"))], +) +async def get_notification_settings_endpoint() -> NotificationSettings: + """Return the current threshold for every known event type. + + Defaults are applied server-side (see + `database.NOTIFICATION_THRESHOLD_DEFAULTS`). The response is + deterministic in event ordering so the UI can render without + extra sorting. + """ + rows = await get_notification_settings() + return NotificationSettings(thresholds=rows) + + +@router.patch( + "/notifications", + response_model=NotificationSettings, + dependencies=[Depends(require_scope("admin"))], +) +async def patch_notification_settings_endpoint( + body: NotificationSettingsPatch, +) -> NotificationSettings: + """Upsert one or more thresholds; return the full post-update state. + + Duplicate event_type entries in a single request are tolerated; + the last value wins (insertion order). Unspecified events are + untouched. + """ + seen: dict[str, NotificationThresholdSetting] = {} + for entry in body.thresholds: + await upsert_notification_threshold(entry.event_type.value, entry.min_severity) + seen[entry.event_type.value] = NotificationThresholdSetting( + event_type=entry.event_type, + min_severity=entry.min_severity, + ) + rows = await get_notification_settings() + return NotificationSettings(thresholds=rows) diff --git a/backend/securescan/database.py b/backend/securescan/database.py index d59e5dd..61cc5d0 100644 --- a/backend/securescan/database.py +++ b/backend/securescan/database.py @@ -13,7 +13,9 @@ FindingState, FindingWithState, Notification, + NotificationEventType, NotificationSeverity, + NotificationThresholdSetting, SBOMComponent, SBOMDocument, Scan, @@ -997,6 +999,109 @@ async def prune_old_notifications(older_than_days: int = 30) -> int: await db.close() +# --- Notification settings (issue #6) ------------------------------------ +# +# Per-event-type minimum severity thresholds. The table is populated +# lazily: a missing row means "use the application default" so existing +# deployments upgrade without behavior change. Defaults are kept in code +# (here) rather than seeded via a migration so they are easy to tune in +# a future release without writing a data migration. + +# Default minimum severity per event type. `medium` for scan.complete +# is an intentional, documented approximation of the pre-issue-6 rule +# ("any non-zero findings_count fires"); see migration 007 for the +# rationale. `info` is the lowest threshold and effectively means +# "always notify" -- fail events are synthesized with severity +# `critical` so any threshold up to and including `critical` will pass. +NOTIFICATION_THRESHOLD_DEFAULTS: dict[str, Severity] = { + NotificationEventType.SCAN_COMPLETE.value: Severity.MEDIUM, + NotificationEventType.SCAN_FAILED.value: Severity.INFO, + NotificationEventType.SCANNER_FAILED.value: Severity.INFO, +} + + +async def get_notification_settings() -> list[NotificationThresholdSetting]: + """Return one threshold per known event type, with defaults filled in. + + Always returns a row for every value of `NotificationEventType`, + even when the table is empty. Order matches the enum declaration + so the UI renders deterministically. + """ + db = await _get_db() + try: + async with db.execute( + "SELECT event_type, min_severity, updated_at FROM notification_settings" + ) as cursor: + rows = {r["event_type"]: r for r in await cursor.fetchall()} + finally: + await db.close() + out: list[NotificationThresholdSetting] = [] + for event in NotificationEventType: + row = rows.get(event.value) + if row is None: + out.append( + NotificationThresholdSetting( + event_type=event, + min_severity=NOTIFICATION_THRESHOLD_DEFAULTS[event.value], + updated_at=None, + ) + ) + else: + out.append( + NotificationThresholdSetting( + event_type=event, + min_severity=Severity(row["min_severity"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + ) + ) + return out + + +async def get_notification_threshold(event_type: str) -> Severity: + """Return the configured min severity for ``event_type``, or its default. + + Convenience wrapper used by the dispatcher hot-path. A separate + query (rather than reusing `get_notification_settings`) keeps the + per-event work to a single SELECT on a tiny table. + """ + default = NOTIFICATION_THRESHOLD_DEFAULTS.get(event_type, Severity.INFO) + db = await _get_db() + try: + async with db.execute( + "SELECT min_severity FROM notification_settings WHERE event_type = ?", + (event_type,), + ) as cursor: + row = await cursor.fetchone() + finally: + await db.close() + if row is None: + return default + try: + return Severity(row["min_severity"]) + except ValueError: + # Defensive: a manually-edited DB could hold a stale value. + # Fall back to the default rather than throwing in the + # notification dispatch path. + return default + + +async def upsert_notification_threshold(event_type: str, min_severity: Severity) -> None: + """Insert or replace one row of `notification_settings`.""" + now = datetime.utcnow().isoformat() + db = await _get_db() + try: + await db.execute( + "INSERT INTO notification_settings (event_type, min_severity, updated_at) " + "VALUES (?, ?, ?) " + "ON CONFLICT(event_type) DO UPDATE SET " + "min_severity = excluded.min_severity, updated_at = excluded.updated_at", + (event_type, min_severity.value, now), + ) + await db.commit() + finally: + await db.close() + + # --- Outbound webhooks (BE-WEBHOOKS) ------------------------------------- # # Two tables, one queue: diff --git a/backend/securescan/main.py b/backend/securescan/main.py index d36ab95..f3adb5b 100644 --- a/backend/securescan/main.py +++ b/backend/securescan/main.py @@ -20,6 +20,7 @@ from .api.sbom import router as sbom_router from .api.scans import router as scans_router from .api.schedules import router as schedules_router +from .api.settings import router as settings_router from .api.triage import router as triage_router from .api.versioning import alias_router_at_v1 from .api.webhooks import router as webhooks_router @@ -54,6 +55,7 @@ triage_router, keys_router, notifications_router, + settings_router, webhooks_router, schedules_router, ): diff --git a/backend/securescan/migrations/007_add_notification_settings.py b/backend/securescan/migrations/007_add_notification_settings.py new file mode 100644 index 0000000..db3d02a --- /dev/null +++ b/backend/securescan/migrations/007_add_notification_settings.py @@ -0,0 +1,39 @@ +"""Per-event-type notification threshold settings (issue #6). + +Adds a `notification_settings` table holding one row per notification +event type with a configurable minimum severity threshold. Rows are NOT +seeded by this migration; the application layer applies defaults when +no row exists for an event type so upgrades are no-ops. + +Defaults (defined in the application layer, see +`securescan.database.NOTIFICATION_DEFAULTS`): + +* scan.complete -> medium (approximates today's "any finding fires" + behavior; pre-issue-6 the bell rang for + any non-zero findings_count, including + info/low. medium was chosen as the + sensible default per the issue brief.) +* scan.failed -> info (always notify; matches today) +* scanner.failed -> info (always notify; matches today) + +Idempotent: skipped on re-apply via CREATE TABLE IF NOT EXISTS. +""" + +import aiosqlite + +VERSION = 7 +DESCRIPTION = "Add notification_settings table for per-event-type thresholds" + + +async def up(conn: aiosqlite.Connection) -> None: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS notification_settings ( + event_type TEXT PRIMARY KEY, + min_severity TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + + +async def down(conn: aiosqlite.Connection) -> None: + await conn.execute("DROP TABLE IF EXISTS notification_settings") diff --git a/backend/securescan/models.py b/backend/securescan/models.py index 179f54e..80b9bb9 100644 --- a/backend/securescan/models.py +++ b/backend/securescan/models.py @@ -216,6 +216,44 @@ class NotificationSeverity(str, Enum): ERROR = "error" +class NotificationEventType(str, Enum): + """Event types that can trigger an in-app notification. + + Currently the dispatcher only persists notifications for these + three events. Adding a new value here also requires extending + `_create_notification_for_event` so the threshold is consulted + consistently. + """ + + SCAN_COMPLETE = "scan.complete" + SCAN_FAILED = "scan.failed" + SCANNER_FAILED = "scanner.failed" + + +class NotificationThresholdSetting(BaseModel): + """Per-event minimum severity threshold for notification dispatch. + + `min_severity` is one of the finding `Severity` values + (info/low/medium/high/critical). Lower means "fire for more + events"; setting "info" matches the pre-issue-6 always-fire + behavior for failure events. + """ + + event_type: NotificationEventType + min_severity: Severity + updated_at: datetime | None = None + + +class NotificationSettings(BaseModel): + """Aggregate response for `GET /api/v1/settings/notifications`. + + Returns one entry per known event type, with defaults filled in + server-side when the row is absent. + """ + + thresholds: list[NotificationThresholdSetting] + + class Notification(BaseModel): """In-app notification surfaced in the dashboard topbar bell. diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index bb70896..a31f765 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -20,6 +20,7 @@ "sbom_components", "api_keys", "notifications", + "notification_settings", "webhooks", "webhook_deliveries", "schedules", @@ -82,6 +83,18 @@ async def _get_max_version(db: aiosqlite.Connection) -> int: return row[0] if row and row[0] is not None else 0 +def _all_migration_versions() -> list[int]: + """Versions of every migration module shipped in the package, sorted asc. + + Computed from the modules themselves so adding a new migration + (e.g. issue #6 added VERSION=7 with 6 reserved for an unrelated + feature) does not require updating every assertion. + """ + from securescan.migrations import _load_all_migrations + + return [m.VERSION for m in _load_all_migrations()] + + async def _build_legacy_db(db: aiosqlite.Connection) -> None: """Simulate a pre-migration-system database (scans + findings only, no extra columns).""" await db.execute(""" @@ -143,7 +156,7 @@ async def test_fresh_db_max_version(tmp_path): await run_migrations(db) max_v = await _get_max_version(db) - assert max_v == 6 + assert max_v == max(_all_migration_versions()) @pytest.mark.asyncio @@ -208,7 +221,7 @@ async def test_forward_migration_from_v1(tmp_path): assert EXPECTED_TABLES.issubset(tables) assert EXPECTED_SCANS_COLS.issubset(scans_cols) assert EXPECTED_FINDINGS_COLS.issubset(findings_cols) - assert max_v == 6 + assert max_v == max(_all_migration_versions()) @pytest.mark.asyncio @@ -226,8 +239,10 @@ async def test_idempotency(tmp_path): row = await cur.fetchone() count = row[0] - assert v_after_first == v_after_second == 6 - assert count == 6 # exactly one row per migration version + expected_max = max(_all_migration_versions()) + expected_count = len(_all_migration_versions()) + assert v_after_first == v_after_second == expected_max + assert count == expected_count # exactly one row per migration version @pytest.mark.asyncio @@ -246,7 +261,7 @@ async def test_preexisting_legacy_db(tmp_path): assert EXPECTED_TABLES.issubset(tables) assert EXPECTED_SCANS_COLS.issubset(scans_cols) assert EXPECTED_FINDINGS_COLS.issubset(findings_cols) - assert max_v == 6 + assert max_v == max(_all_migration_versions()) @pytest.mark.asyncio @@ -277,4 +292,4 @@ async def test_schema_version_table_records_all_versions(tmp_path): async with db.execute("SELECT version FROM schema_version ORDER BY version") as cur: rows = await cur.fetchall() versions = [r[0] for r in rows] - assert versions == [1, 2, 3, 4, 5, 6] + assert versions == sorted(_all_migration_versions()) diff --git a/backend/tests/test_notification_settings.py b/backend/tests/test_notification_settings.py new file mode 100644 index 0000000..ed19e82 --- /dev/null +++ b/backend/tests/test_notification_settings.py @@ -0,0 +1,291 @@ +"""Tests for per-event notification thresholds (issue #6). + +Covers: +- DB defaults: with no row in `notification_settings`, helpers return + the application defaults. +- DB upsert: writes persist and override defaults on the next read. +- API: GET returns defaults on a fresh DB; PATCH updates and the + follow-up GET reflects the new value. +- Dispatcher integration: with the threshold set high a medium + finding does NOT create a notification, and a critical one does. +- Backward compatibility: with no settings rows configured the + dispatcher's behavior matches the pre-issue-6 hard-coded rules + (scan.complete with findings -> notify; clean scan -> no notify; + scan.failed / scanner.failed -> always notify). +""" + +from __future__ import annotations + +import asyncio + +import pytest +from fastapi.testclient import TestClient + +from securescan.api.scans import _create_notification_for_event +from securescan.database import ( + NOTIFICATION_THRESHOLD_DEFAULTS, + get_notification_settings, + get_notification_threshold, + init_db, + list_notifications, + set_db_path, + upsert_notification_threshold, +) +from securescan.main import app +from securescan.models import ( + NotificationEventType, + Severity, +) + + +@pytest.fixture +def temp_db(tmp_path, monkeypatch): + """Fresh DB per test; reset global path on teardown.""" + from securescan.config import settings as _settings + + db_path = str(tmp_path / "notif_settings.db") + original = _settings.database_path + set_db_path(db_path) + asyncio.run(init_db()) + monkeypatch.delenv("SECURESCAN_API_KEY", raising=False) + yield db_path + set_db_path(original) + + +@pytest.fixture +def client(temp_db) -> TestClient: + with TestClient(app) as c: + yield c + + +def _run(coro): + return asyncio.run(coro) + + +# --------------------------------------------------------------------------- +# Database layer +# --------------------------------------------------------------------------- + + +def test_defaults_returned_when_table_empty(temp_db): + rows = _run(get_notification_settings()) + by_event = {r.event_type.value: r for r in rows} + # Every known event type appears in the response. + assert set(by_event) == {e.value for e in NotificationEventType} + # Defaults match the documented rule set. + assert by_event["scan.complete"].min_severity == Severity.MEDIUM + assert by_event["scan.failed"].min_severity == Severity.INFO + assert by_event["scanner.failed"].min_severity == Severity.INFO + # No row in the table yet -> updated_at is None on every entry. + assert all(r.updated_at is None for r in rows) + + +def test_get_notification_threshold_uses_default(temp_db): + sev = _run(get_notification_threshold("scan.complete")) + assert sev == NOTIFICATION_THRESHOLD_DEFAULTS["scan.complete"] + + +def test_upsert_persists_and_overrides_default(temp_db): + async def _go(): + await upsert_notification_threshold("scan.complete", Severity.HIGH) + return await get_notification_threshold("scan.complete") + + assert _run(_go()) == Severity.HIGH + + +def test_upsert_idempotent_on_same_key(temp_db): + async def _go(): + await upsert_notification_threshold("scan.complete", Severity.HIGH) + await upsert_notification_threshold("scan.complete", Severity.LOW) + rows = await get_notification_settings() + return [(r.event_type.value, r.min_severity) for r in rows] + + rows = _run(_go()) + by_event = dict(rows) + assert by_event["scan.complete"] == Severity.LOW + # Untouched events keep their defaults. + assert by_event["scan.failed"] == Severity.INFO + + +# --------------------------------------------------------------------------- +# HTTP API +# --------------------------------------------------------------------------- + + +def test_get_settings_returns_defaults(client): + res = client.get("/api/v1/settings/notifications") + assert res.status_code == 200 + data = res.json() + by_event = {row["event_type"]: row for row in data["thresholds"]} + assert by_event["scan.complete"]["min_severity"] == "medium" + assert by_event["scan.failed"]["min_severity"] == "info" + assert by_event["scanner.failed"]["min_severity"] == "info" + + +def test_patch_updates_and_persists(client): + res = client.patch( + "/api/v1/settings/notifications", + json={ + "thresholds": [ + {"event_type": "scan.complete", "min_severity": "critical"}, + ] + }, + ) + assert res.status_code == 200 + data = res.json() + by_event = {row["event_type"]: row for row in data["thresholds"]} + assert by_event["scan.complete"]["min_severity"] == "critical" + # Other events remain at defaults. + assert by_event["scan.failed"]["min_severity"] == "info" + + # Persisted: a follow-up GET sees the new value. + res2 = client.get("/api/v1/settings/notifications") + by_event2 = {row["event_type"]: row for row in res2.json()["thresholds"]} + assert by_event2["scan.complete"]["min_severity"] == "critical" + + +def test_patch_rejects_unknown_event(client): + res = client.patch( + "/api/v1/settings/notifications", + json={ + "thresholds": [ + {"event_type": "scan.bogus", "min_severity": "low"}, + ] + }, + ) + assert res.status_code == 422 + + +def test_patch_rejects_unknown_severity(client): + res = client.patch( + "/api/v1/settings/notifications", + json={ + "thresholds": [ + {"event_type": "scan.complete", "min_severity": "blocker"}, + ] + }, + ) + assert res.status_code == 422 + + +def test_patch_empty_body_is_noop(client): + res = client.patch( + "/api/v1/settings/notifications", + json={"thresholds": []}, + ) + assert res.status_code == 200 + by_event = {r["event_type"]: r for r in res.json()["thresholds"]} + assert by_event["scan.complete"]["min_severity"] == "medium" + + +# --------------------------------------------------------------------------- +# Dispatcher integration +# --------------------------------------------------------------------------- + + +def test_dispatcher_suppresses_below_threshold(temp_db): + """Threshold=high; a medium finding event must not create a notification.""" + + async def _go(): + await upsert_notification_threshold("scan.complete", Severity.HIGH) + await _create_notification_for_event( + "scan.complete", + "scan-low", + { + "findings_count": 3, + "max_severity": "medium", + "target": "/proj", + }, + ) + return await list_notifications() + + assert _run(_go()) == [] + + +def test_dispatcher_fires_at_or_above_threshold(temp_db): + """Threshold=high; a critical event creates a notification.""" + + async def _go(): + await upsert_notification_threshold("scan.complete", Severity.HIGH) + await _create_notification_for_event( + "scan.complete", + "scan-crit", + { + "findings_count": 1, + "max_severity": "critical", + "target": "/proj", + }, + ) + return await list_notifications() + + rows = _run(_go()) + assert len(rows) == 1 + assert rows[0].type == "scan.complete" + + +def test_dispatcher_clean_scan_never_notifies(temp_db): + """Even with threshold=info, a clean scan must not buzz the bell.""" + + async def _go(): + await upsert_notification_threshold("scan.complete", Severity.INFO) + await _create_notification_for_event( + "scan.complete", + "scan-clean", + {"findings_count": 0, "max_severity": None, "target": "/proj"}, + ) + return await list_notifications() + + assert _run(_go()) == [] + + +# --------------------------------------------------------------------------- +# Backward compatibility (no rows in notification_settings) +# --------------------------------------------------------------------------- + + +def test_backcompat_scan_complete_with_findings_fires_with_no_settings(temp_db): + """No settings row + findings_count>0 -> notification fires (default medium). + + Legacy publish sites that don't pass `max_severity` get the + "assume worst case" treatment so behavior matches pre-issue-6. + """ + + async def _go(): + await _create_notification_for_event( + "scan.complete", + "scan-1", + {"findings_count": 5, "target": "/proj"}, + ) + return await list_notifications() + + rows = _run(_go()) + assert len(rows) == 1 + assert rows[0].type == "scan.complete" + + +def test_backcompat_scan_failed_always_fires_with_no_settings(temp_db): + async def _go(): + await _create_notification_for_event( + "scan.failed", + "scan-2", + {"error": "boom"}, + ) + return await list_notifications() + + rows = _run(_go()) + assert len(rows) == 1 + assert rows[0].type == "scan.failed" + + +def test_backcompat_scanner_failed_always_fires_with_no_settings(temp_db): + async def _go(): + await _create_notification_for_event( + "scanner.failed", + "scan-3", + {"scanner": "bandit", "error": "boom"}, + ) + return await list_notifications() + + rows = _run(_go()) + assert len(rows) == 1 + assert rows[0].type == "scanner.failed" diff --git a/frontend/src/app/settings/notifications/page.tsx b/frontend/src/app/settings/notifications/page.tsx new file mode 100644 index 0000000..cebb913 --- /dev/null +++ b/frontend/src/app/settings/notifications/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Bell, Loader2, Save } from "lucide-react"; + +import { + getNotificationSettings, + updateNotificationSettings, + type NotificationEventType, + type NotificationThresholdSetting, + type ThresholdSeverity, +} from "@/lib/api"; +import { PageHeader } from "@/components/page-header"; + +const SEVERITY_OPTIONS: { value: ThresholdSeverity; label: string }[] = [ + { value: "info", label: "info (always notify)" }, + { value: "low", label: "low" }, + { value: "medium", label: "medium" }, + { value: "high", label: "high" }, + { value: "critical", label: "critical (only criticals)" }, +]; + +const EVENT_LABELS: Record = { + "scan.complete": { + title: "Scan complete", + description: + "Notify when a scan finishes. Threshold is compared against the highest finding severity in the run; clean scans never notify.", + }, + "scan.failed": { + title: "Scan failed", + description: + "Notify when a scan errors out. Treated as critical severity, so any threshold up to and including critical fires.", + }, + "scanner.failed": { + title: "Scanner failed", + description: + "Notify when an individual scanner crashes. Same critical synthesis as scan.failed.", + }, +}; + +const EVENT_ORDER: NotificationEventType[] = [ + "scan.complete", + "scan.failed", + "scanner.failed", +]; + +export default function NotificationSettingsPage() { + const [rows, setRows] = useState(null); + const [draft, setDraft] = useState>({}); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [savedAt, setSavedAt] = useState(null); + + const load = useCallback(async () => { + setError(null); + try { + const data = await getNotificationSettings(); + setRows(data.thresholds); + const next: Record = {}; + for (const r of data.thresholds) { + next[r.event_type] = r.min_severity; + } + setDraft(next); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load settings"); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const dirty = useMemo(() => { + if (!rows) return false; + return rows.some((r) => draft[r.event_type] !== r.min_severity); + }, [rows, draft]); + + const handleSave = useCallback(async () => { + if (!rows || !dirty) return; + setSaving(true); + setError(null); + try { + const updates = rows + .filter((r) => draft[r.event_type] !== r.min_severity) + .map((r) => ({ + event_type: r.event_type, + min_severity: draft[r.event_type], + })); + const data = await updateNotificationSettings(updates); + setRows(data.thresholds); + const next: Record = {}; + for (const r of data.thresholds) { + next[r.event_type] = r.min_severity; + } + setDraft(next); + setSavedAt(Date.now()); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save settings"); + } finally { + setSaving(false); + } + }, [rows, draft, dirty]); + + return ( +
+ + Settings + + } + title="Notifications" + meta="Control which events buzz the dashboard bell. Set a per-event minimum severity threshold; events below the threshold are silently dropped." + /> + + {error && ( +
+ {error} +
+ )} + + {!rows && !error && ( +
+ + Loading… +
+ )} + + {rows && ( +
+ {EVENT_ORDER.map((event) => { + const row = rows.find((r) => r.event_type === event); + if (!row) return null; + const meta = EVENT_LABELS[event]; + const current = draft[event] ?? row.min_severity; + const changed = current !== row.min_severity; + return ( +
+
+
+

+ {meta.title} + + {event} + +

+

{meta.description}

+
+
+ + + {changed && ( + + unsaved + + )} +
+
+
+ ); + })} + +
+ + {!dirty && savedAt && ( + Saved. + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index 30940b3..7617f1a 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -43,6 +43,7 @@ const navItems: NavItem[] = [ { label: "API keys", href: "/settings/keys", icon: KeyRound, group: "settings" }, { label: "Webhooks", href: "/settings/webhooks", icon: Webhook, group: "settings" }, { label: "Schedules", href: "/settings/schedules", icon: CalendarClock, group: "settings" }, + { label: "Notification rules", href: "/settings/notifications", icon: Bell, group: "settings" }, ]; interface RecentScan { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 732bb14..5072776 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1369,3 +1369,61 @@ export async function revokeApiKey(keyId: string): Promise { if (res.status === 404) throw new Error("Key not found."); throw new Error(`Failed to revoke API key (${res.status})`); } + +// --- Notification threshold settings (issue #6) ------------------------- +// +// Per-event minimum severity threshold. Defaults are filled in +// server-side so a freshly-installed deployment renders sensibly +// before the operator changes anything. + +export type NotificationEventType = + | "scan.complete" + | "scan.failed" + | "scanner.failed"; + +export type ThresholdSeverity = + | "critical" + | "high" + | "medium" + | "low" + | "info"; + +export interface NotificationThresholdSetting { + event_type: NotificationEventType; + min_severity: ThresholdSeverity; + updated_at: string | null; +} + +export interface NotificationSettings { + thresholds: NotificationThresholdSetting[]; +} + +export async function getNotificationSettings(): Promise { + const res = await apiFetch(`${API_BASE}/settings/notifications`, { + cache: "no-store", + }); + if (res.ok) return (await res.json()) as NotificationSettings; + throw new Error(`Failed to load notification settings (${res.status})`); +} + +export async function updateNotificationSettings( + thresholds: { event_type: NotificationEventType; min_severity: ThresholdSeverity }[], +): Promise { + const res = await apiFetch(`${API_BASE}/settings/notifications`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ thresholds }), + }); + if (res.ok) return (await res.json()) as NotificationSettings; + if (res.status === 400 || res.status === 422) { + let detail = "Invalid notification settings."; + try { + const data = (await res.json()) as { detail?: string }; + if (data?.detail) detail = data.detail; + } catch { + /* keep default */ + } + throw new Error(detail); + } + throw new Error(`Failed to update notification settings (${res.status})`); +}