fix: skip alerts for hidden event types, show them on host card (0.4.7)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -143,14 +143,22 @@ def list_events(
|
|||||||
type: str | None = None,
|
type: str | None = None,
|
||||||
host_id: int | None = None,
|
host_id: int | None = None,
|
||||||
hostname: str | None = None,
|
hostname: str | None = None,
|
||||||
|
include_hidden: bool = False,
|
||||||
from_time: str | None = Query(None, alias="from"),
|
from_time: str | None = Query(None, alias="from"),
|
||||||
to_time: str | None = Query(None, alias="to"),
|
to_time: str | None = Query(None, alias="to"),
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> EventListResponse:
|
) -> 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)
|
hidden = get_hidden_event_types(db)
|
||||||
type_filter = visibility_type_filter(hidden)
|
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))
|
stmt = select(Event).join(Host).options(joinedload(Event.host))
|
||||||
count_stmt = select(func.count()).select_from(Event).join(Host)
|
count_stmt = select(func.count()).select_from(Event).join(Host)
|
||||||
if type_filter is not None:
|
if type_filter is not None:
|
||||||
@@ -347,14 +355,11 @@ def get_event(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> EventDetail:
|
) -> EventDetail:
|
||||||
hidden = get_hidden_event_types(db)
|
|
||||||
event = db.scalar(
|
event = db.scalar(
|
||||||
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
|
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
|
||||||
)
|
)
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=404, detail="Event not found")
|
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)
|
base = event_to_summary(event, db)
|
||||||
return EventDetail(
|
return EventDetail(
|
||||||
**base.model_dump(),
|
**base.model_dump(),
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool:
|
|||||||
return event_type not in hidden
|
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:
|
def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None:
|
||||||
if not hidden:
|
if not hidden:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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 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_cooldown import should_notify_event, should_notify_problem
|
||||||
from app.services.notification_policy import get_effective_notification_policy
|
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.host_health import HEARTBEAT_TYPE
|
||||||
from app.services.notification_severity import severity_meets_minimum
|
from app.services.notification_severity import severity_meets_minimum
|
||||||
|
|
||||||
@@ -29,6 +30,17 @@ def _event_telegram_via_agent(event: Event) -> bool:
|
|||||||
return via == "agent"
|
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:
|
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
|
||||||
if policy.use_telegram:
|
if policy.use_telegram:
|
||||||
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
|
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.
|
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
|
||||||
if event.type == HEARTBEAT_TYPE:
|
if event.type == HEARTBEAT_TYPE:
|
||||||
return
|
return
|
||||||
|
if _skip_notifications_for_hidden_event(event, db):
|
||||||
|
return
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
if not severity_meets_minimum(event.severity, policy.min_severity):
|
if not severity_meets_minimum(event.severity, policy.min_severity):
|
||||||
return
|
return
|
||||||
@@ -89,6 +103,8 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
|||||||
|
|
||||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||||
"""
|
"""
|
||||||
|
if _skip_notifications_for_hidden_event(event, db):
|
||||||
|
return
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
if not should_notify_event(event, db):
|
if not should_notify_event(event, db):
|
||||||
return
|
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:
|
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
|
||||||
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
||||||
|
if _skip_notifications_for_hidden_event(event, db):
|
||||||
|
return
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
if not should_notify_event(event, db):
|
if not should_notify_event(event, db):
|
||||||
return
|
return
|
||||||
@@ -108,6 +126,8 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
|
|||||||
|
|
||||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||||
"""
|
"""
|
||||||
|
if _skip_notifications_for_hidden_event(event, db):
|
||||||
|
return
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
if not should_notify_event(event, db):
|
if not should_notify_event(event, db):
|
||||||
return
|
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:
|
def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
|
||||||
"""RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login)."""
|
"""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)
|
policy = get_effective_notification_policy(db)
|
||||||
if not should_notify_event(event, db):
|
if not should_notify_event(event, db):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.4.6"
|
APP_VERSION = "0.4.7"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -55,6 +55,47 @@ def test_hidden_event_type_returns_404_on_detail(client, auth_headers, jwt_heade
|
|||||||
assert listed.json()["total"] == 0
|
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):
|
def test_visibility_settings_api(client, jwt_headers, db_session):
|
||||||
put = client.put(
|
put = client.put(
|
||||||
"/api/v1/settings/notifications/severity-overrides",
|
"/api/v1/settings/notifications/severity-overrides",
|
||||||
|
|||||||
@@ -124,6 +124,44 @@ def test_notify_event_skipped_by_cooldown():
|
|||||||
mock_tg.notify_event.assert_not_called()
|
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():
|
def test_notify_auth_login_bypasses_min_severity():
|
||||||
event = Event(
|
event = Event(
|
||||||
event_id="00000000-0000-4000-8000-000000000601",
|
event_id="00000000-0000-4000-8000-000000000601",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -796,6 +796,7 @@ async function loadEvents(page = 1) {
|
|||||||
page: String(page),
|
page: String(page),
|
||||||
page_size: String(eventsPageSize),
|
page_size: String(eventsPageSize),
|
||||||
host_id: String(hostId.value),
|
host_id: String(hostId.value),
|
||||||
|
include_hidden: "true",
|
||||||
});
|
});
|
||||||
events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
|
events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user