From daeb7e965d1010f91c317bc9b6b9955175456c7b Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 29 Jun 2026 15:16:10 +1000 Subject: [PATCH] fix: skip alerts for hidden event types, show them on host card (0.4.7) Co-authored-by: Cursor --- backend/app/api/v1/events.py | 11 +++-- backend/app/services/event_type_visibility.py | 8 ++++ backend/app/services/notify_dispatch.py | 22 ++++++++++ backend/app/version.py | 2 +- backend/tests/test_event_type_visibility.py | 41 +++++++++++++++++++ backend/tests/test_notify_dispatch.py | 38 +++++++++++++++++ frontend/src/version.ts | 2 +- frontend/src/views/HostDetailView.vue | 1 + 8 files changed, 120 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 7a0803c..4b311bb 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -143,14 +143,22 @@ def list_events( type: str | None = None, host_id: int | None = None, hostname: str | None = None, + include_hidden: bool = False, from_time: str | None = Query(None, alias="from"), to_time: str | None = Query(None, alias="to"), q: str | None = None, db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> EventListResponse: + if include_hidden and host_id is None: + raise HTTPException( + status_code=400, + detail="include_hidden requires host_id", + ) hidden = get_hidden_event_types(db) type_filter = visibility_type_filter(hidden) + if include_hidden and host_id is not None: + type_filter = None stmt = select(Event).join(Host).options(joinedload(Event.host)) count_stmt = select(func.count()).select_from(Event).join(Host) if type_filter is not None: @@ -347,14 +355,11 @@ def get_event( db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> EventDetail: - hidden = get_hidden_event_types(db) event = db.scalar( select(Event).where(Event.id == event_db_id).options(joinedload(Event.host)) ) if event is None: raise HTTPException(status_code=404, detail="Event not found") - if event.type in hidden: - raise HTTPException(status_code=404, detail="Event not found") base = event_to_summary(event, db) return EventDetail( **base.model_dump(), diff --git a/backend/app/services/event_type_visibility.py b/backend/app/services/event_type_visibility.py index cf3c0d8..7eb1d1c 100644 --- a/backend/app/services/event_type_visibility.py +++ b/backend/app/services/event_type_visibility.py @@ -26,6 +26,14 @@ def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool: return event_type not in hidden +def event_type_notifications_enabled(event_type: str, db: Session | None) -> bool: + """Outbound alerts (Telegram/push/email/webhook) only for types visible in events UI.""" + if db is None: + return True + hidden = get_hidden_event_types(db) + return show_in_events_for(event_type, hidden=hidden) + + def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None: if not hidden: return None diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py index 9ca64b0..06756ca 100644 --- a/backend/app/services/notify_dispatch.py +++ b/backend/app/services/notify_dispatch.py @@ -8,6 +8,7 @@ from app.models import Event, Problem from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify from app.services.notification_cooldown import should_notify_event, should_notify_problem from app.services.notification_policy import get_effective_notification_policy +from app.services.event_type_visibility import event_type_notifications_enabled from app.services.host_health import HEARTBEAT_TYPE from app.services.notification_severity import severity_meets_minimum @@ -29,6 +30,17 @@ def _event_telegram_via_agent(event: Event) -> bool: return via == "agent" +def _skip_notifications_for_hidden_event(event: Event, db: Session | None) -> bool: + if event_type_notifications_enabled(event.type, db): + return False + logger.info( + "notify skipped hidden event type=%s event_id=%s", + event.type, + event.event_id, + ) + return True + + def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None: if policy.use_telegram: telegram_notify.notify_event(event, db=db, apply_policy_gate=False) @@ -55,6 +67,8 @@ def notify_event(event: Event, *, db: Session | None = None) -> None: # Heartbeat — только для UI/статуса хоста, не для Telegram/email/push. if event.type == HEARTBEAT_TYPE: return + if _skip_notifications_for_hidden_event(event, db): + return policy = get_effective_notification_policy(db) if not severity_meets_minimum(event.severity, policy.min_severity): return @@ -89,6 +103,8 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None: При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует. """ + if _skip_notifications_for_hidden_event(event, db): + return policy = get_effective_notification_policy(db) if not should_notify_event(event, db): return @@ -97,6 +113,8 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None: def notify_lifecycle(event: Event, *, db: Session | None = None) -> None: """Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent).""" + if _skip_notifications_for_hidden_event(event, db): + return policy = get_effective_notification_policy(db) if not should_notify_event(event, db): return @@ -108,6 +126,8 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None: При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует. """ + if _skip_notifications_for_hidden_event(event, db): + return policy = get_effective_notification_policy(db) if not should_notify_event(event, db): return @@ -116,6 +136,8 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None: def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None: """RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login).""" + if _skip_notifications_for_hidden_event(event, db): + return policy = get_effective_notification_policy(db) if not should_notify_event(event, db): return diff --git a/backend/app/version.py b/backend/app/version.py index 2752425..e1be65f 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.4.6" +APP_VERSION = "0.4.7" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_event_type_visibility.py b/backend/tests/test_event_type_visibility.py index d1b27c8..c16b293 100644 --- a/backend/tests/test_event_type_visibility.py +++ b/backend/tests/test_event_type_visibility.py @@ -55,6 +55,47 @@ def test_hidden_event_type_returns_404_on_detail(client, auth_headers, jwt_heade assert listed.json()["total"] == 0 +def test_hidden_event_type_visible_on_host_detail_list(client, auth_headers, jwt_headers, db_session): + from app.models import Host + + host = Host(hostname="inv-pc", os_family="windows", product="rdp-login-monitor") + db_session.add(host) + db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False)) + db_session.commit() + + payload = { + **VALID_EVENT, + "type": "agent.inventory", + "category": "agent", + "title": "Inventory", + "summary": "hw", + "host": {"hostname": "inv-pc", "os_family": "windows"}, + "details": {"inventory": {"memory_gb": 16}}, + } + _ingest(client, auth_headers, payload) + + global_list = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.inventory"}) + assert global_list.json()["total"] == 0 + + host_list = client.get( + "/api/v1/events", + headers=jwt_headers, + params={"host_id": host.id, "include_hidden": "true"}, + ) + assert host_list.status_code == 200 + assert host_list.json()["total"] == 1 + + event_db_id = host_list.json()["items"][0]["id"] + detail = client.get(f"/api/v1/events/{event_db_id}", headers=jwt_headers) + assert detail.status_code == 200 + assert detail.json()["type"] == "agent.inventory" + + +def test_include_hidden_requires_host_id(client, jwt_headers): + r = client.get("/api/v1/events", headers=jwt_headers, params={"include_hidden": "true"}) + assert r.status_code == 400 + + def test_visibility_settings_api(client, jwt_headers, db_session): put = client.put( "/api/v1/settings/notifications/severity-overrides", diff --git a/backend/tests/test_notify_dispatch.py b/backend/tests/test_notify_dispatch.py index 7c0d5f4..c03e59a 100644 --- a/backend/tests/test_notify_dispatch.py +++ b/backend/tests/test_notify_dispatch.py @@ -124,6 +124,44 @@ def test_notify_event_skipped_by_cooldown(): mock_tg.notify_event.assert_not_called() +def test_notify_event_skips_hidden_event_type(db_session): + from app.models.event_type_visibility import EventTypeVisibility + + db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False)) + db_session.commit() + + event = Event( + event_id="00000000-0000-4000-8000-000000000701", + host_id=1, + occurred_at=datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc), + category="agent", + type="agent.inventory", + severity="warning", + title="Hardware changed", + summary="memory", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=True, + use_mobile=True, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "should_notify_event", return_value=True): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + with patch.object(notify_dispatch, "email_notify") as mock_em: + with patch.object(notify_dispatch, "mobile_notify") as mock_mob: + notify_dispatch.notify_event(event, db=db_session) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_not_called() + mock_em.notify_event.assert_not_called() + mock_mob.notify_event.assert_not_called() + + def test_notify_auth_login_bypasses_min_severity(): event = Event( event_id="00000000-0000-4000-8000-000000000601", diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 401e843..a7532a8 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.4.6"; +export const APP_VERSION = "0.4.7"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index aeb57fb..04c3111 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -796,6 +796,7 @@ async function loadEvents(page = 1) { page: String(page), page_size: String(eventsPageSize), host_id: String(hostId.value), + include_hidden: "true", }); events.value = await apiFetch(`/api/v1/events?${params}`); } catch (e) {