diff --git a/backend/alembic/versions/007_notification_policy.py b/backend/alembic/versions/007_notification_policy.py new file mode 100644 index 0000000..c200ce2 --- /dev/null +++ b/backend/alembic/versions/007_notification_policy.py @@ -0,0 +1,37 @@ +"""notification_policy singleton (global severity rule) + +Revision ID: 007 +Revises: 006 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "007" +down_revision: Union[str, None] = "006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notification_policy", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("min_severity", sa.String(length=16), nullable=False, server_default="warning"), + sa.Column("use_telegram", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("use_webhook", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("use_email", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.PrimaryKeyConstraint("id"), + ) + op.execute( + sa.text( + "INSERT INTO notification_policy (id, min_severity, use_telegram, use_webhook, use_email) " + "VALUES (1, 'warning', true, false, false)" + ) + ) + + +def downgrade() -> None: + op.drop_table("notification_policy") diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 20d2093..16a4b1b 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -5,6 +5,11 @@ from sqlalchemy.orm import Session from app.auth.jwt_auth import get_current_user from app.database import get_db from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message +from app.services.notification_policy import ( + NotificationPolicyConfig, + get_effective_notification_policy, + upsert_notification_policy, +) from app.services.notification_settings import ( VALID_SEVERITIES, EmailConfig, @@ -75,22 +80,36 @@ class EmailSettingsResponse(BaseModel): source: str = Field(description="env или db") +class NotificationPolicyResponse(BaseModel): + min_severity: str + use_telegram: bool + use_webhook: bool + use_email: bool + source: str = Field(description="env или db") + + class NotificationSettingsResponse(BaseModel): + policy: NotificationPolicyResponse telegram: TelegramSettingsResponse webhook: WebhookSettingsResponse email: EmailSettingsResponse +class NotificationPolicyUpdate(BaseModel): + min_severity: str + use_telegram: bool + use_webhook: bool + use_email: bool + + class TelegramSettingsUpdate(BaseModel): enabled: bool - min_severity: str bot_token: str | None = None chat_id: str | None = None class WebhookSettingsUpdate(BaseModel): enabled: bool - min_severity: str url: str | None = None secret_header: str | None = None secret: str | None = None @@ -98,7 +117,6 @@ class WebhookSettingsUpdate(BaseModel): class EmailSettingsUpdate(BaseModel): enabled: bool - min_severity: str smtp_host: str | None = None smtp_port: int | None = None smtp_user: str | None = None @@ -114,30 +132,40 @@ class ChannelTestResponse(BaseModel): message: str -def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse: +def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResponse: + return NotificationPolicyResponse( + min_severity=cfg.min_severity, + use_telegram=cfg.use_telegram, + use_webhook=cfg.use_webhook, + use_email=cfg.use_email, + source=cfg.source, + ) + + +def _telegram_to_response(cfg: TelegramConfig, policy: NotificationPolicyConfig) -> TelegramSettingsResponse: return TelegramSettingsResponse( enabled=cfg.enabled, configured=cfg.configured, chat_id_hint=_mask_secret(cfg.chat_id), bot_token_hint=_mask_secret(cfg.bot_token), - min_severity=cfg.min_severity, + min_severity=policy.min_severity, source=cfg.source, ) -def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse: +def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse: return WebhookSettingsResponse( enabled=cfg.enabled, configured=cfg.configured, url_hint=_mask_url(cfg.url), secret_header=cfg.secret_header or None, secret_hint=_mask_secret(cfg.secret), - min_severity=cfg.min_severity, + min_severity=policy.min_severity, source=cfg.source, ) -def _email_to_response(cfg: EmailConfig) -> EmailSettingsResponse: +def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse: return EmailSettingsResponse( enabled=cfg.enabled, configured=cfg.configured, @@ -149,7 +177,7 @@ def _email_to_response(cfg: EmailConfig) -> EmailSettingsResponse: mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None, smtp_starttls=cfg.smtp_starttls, smtp_ssl=cfg.smtp_ssl, - min_severity=cfg.min_severity, + min_severity=policy.min_severity, source=cfg.source, ) @@ -159,32 +187,55 @@ def get_notification_settings( db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> NotificationSettingsResponse: + policy = get_effective_notification_policy(db) return NotificationSettingsResponse( - telegram=_telegram_to_response(get_effective_telegram_config(db)), - webhook=_webhook_to_response(get_effective_webhook_config(db)), - email=_email_to_response(get_effective_email_config(db)), + policy=_policy_to_response(policy), + telegram=_telegram_to_response(get_effective_telegram_config(db), policy), + webhook=_webhook_to_response(get_effective_webhook_config(db), policy), + email=_email_to_response(get_effective_email_config(db), policy), ) +@router.put("/notifications/policy", response_model=NotificationPolicyResponse) +def update_notification_policy( + body: NotificationPolicyUpdate, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> NotificationPolicyResponse: + if body.min_severity not in VALID_SEVERITIES: + raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") + if not any((body.use_telegram, body.use_webhook, body.use_email)): + raise HTTPException(status_code=422, detail="Выберите хотя бы один канал") + try: + policy = upsert_notification_policy( + db, + min_severity=body.min_severity, + use_telegram=body.use_telegram, + use_webhook=body.use_webhook, + use_email=body.use_email, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return _policy_to_response(policy) + + @router.put("/notifications/telegram", response_model=TelegramSettingsResponse) def update_telegram_settings( body: TelegramSettingsUpdate, db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> TelegramSettingsResponse: - if body.min_severity not in VALID_SEVERITIES: - raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") try: cfg = upsert_telegram_channel( db, enabled=body.enabled, - min_severity=body.min_severity, bot_token=body.bot_token, chat_id=body.chat_id, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - return _telegram_to_response(cfg) + policy = get_effective_notification_policy(db) + return _telegram_to_response(cfg, policy) @router.put("/notifications/webhook", response_model=WebhookSettingsResponse) @@ -193,20 +244,18 @@ def update_webhook_settings( db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> WebhookSettingsResponse: - if body.min_severity not in VALID_SEVERITIES: - raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") try: cfg = upsert_webhook_channel( db, enabled=body.enabled, - min_severity=body.min_severity, url=body.url, secret_header=body.secret_header, secret=body.secret, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - return _webhook_to_response(cfg) + policy = get_effective_notification_policy(db) + return _webhook_to_response(cfg, policy) @router.put("/notifications/email", response_model=EmailSettingsResponse) @@ -215,13 +264,10 @@ def update_email_settings( db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> EmailSettingsResponse: - if body.min_severity not in VALID_SEVERITIES: - raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}") try: cfg = upsert_email_channel( db, enabled=body.enabled, - min_severity=body.min_severity, smtp_host=body.smtp_host, smtp_port=body.smtp_port, smtp_user=body.smtp_user, @@ -233,7 +279,8 @@ def update_email_settings( ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc - return _email_to_response(cfg) + policy = get_effective_notification_policy(db) + return _email_to_response(cfg, policy) @router.post("/notifications/telegram/test", response_model=ChannelTestResponse) diff --git a/backend/app/config.py b/backend/app/config.py index eae295f..9f36371 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -67,6 +67,9 @@ class Settings(BaseSettings): smtp_ssl: bool = False smtp_min_severity: str = "high" + notify_min_severity: str = "warning" + notify_channels: str = "telegram,webhook,email" + # Порог «живости» агента sac_heartbeat_stale_minutes: int = 780 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3ac4841..52c8ac1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.api_key import ApiKey from app.models.event import Event from app.models.host import Host from app.models.notification_channel import NotificationChannel +from app.models.notification_policy import NotificationPolicy from app.models.problem import Problem, ProblemEvent -__all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "Problem", "ProblemEvent"] +__all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "NotificationPolicy", "Problem", "ProblemEvent"] diff --git a/backend/app/models/notification_policy.py b/backend/app/models/notification_policy.py new file mode 100644 index 0000000..189aefb --- /dev/null +++ b/backend/app/models/notification_policy.py @@ -0,0 +1,18 @@ +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + +POLICY_ROW_ID = 1 + + +class NotificationPolicy(Base): + """Singleton: глобальное правило severity → каналы (F-NOT-02 урезанно).""" + + __tablename__ = "notification_policy" + + id: Mapped[int] = mapped_column(primary_key=True) + min_severity: Mapped[str] = mapped_column(String(16), default="warning") + use_telegram: Mapped[bool] = mapped_column(Boolean, default=True) + use_webhook: Mapped[bool] = mapped_column(Boolean, default=False) + use_email: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/backend/app/services/email_notify.py b/backend/app/services/email_notify.py index 2465fec..0557b36 100644 --- a/backend/app/services/email_notify.py +++ b/backend/app/services/email_notify.py @@ -10,8 +10,8 @@ from email.mime.text import MIMEText from sqlalchemy.orm import Session from app.models import Event, Problem +from app.services.notification_severity import severity_meets_minimum from app.services.notification_settings import EmailConfig, get_effective_email_config -from app.services.telegram_notify import SEVERITY_ORDER logger = logging.getLogger(__name__) @@ -24,14 +24,6 @@ class EmailSendError(Exception): pass -def _severity_value(value: str) -> int: - return SEVERITY_ORDER.get(value, 0) - - -def _should_notify_severity(severity: str, *, config: EmailConfig) -> bool: - return _severity_value(severity) >= _severity_value(config.min_severity) - - def parse_mail_recipients(mail_to: str) -> list[str]: return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()] @@ -94,9 +86,11 @@ def send_email_test_message(*, config: EmailConfig | None = None) -> None: ) -def notify_event(event: Event, *, db: Session | None = None) -> None: +def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: cfg = get_effective_email_config(db) - if not cfg.active or not _should_notify_severity(event.severity, config=cfg): + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity): return host = event.host.hostname if event.host else "unknown" body = ( @@ -113,9 +107,11 @@ def notify_event(event: Event, *, db: Session | None = None) -> None: logger.exception("notify_event email failed event_id=%s", event.event_id) -def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: cfg = get_effective_email_config(db) - if not cfg.active or not _should_notify_severity(problem.severity, config=cfg): + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity): return host = problem.host.hostname if problem.host else "unknown" related = "" diff --git a/backend/app/services/notification_policy.py b/backend/app/services/notification_policy.py new file mode 100644 index 0000000..6df53e2 --- /dev/null +++ b/backend/app/services/notification_policy.py @@ -0,0 +1,130 @@ +"""Global notification policy: severity ≥ min → selected channels.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.notification_channel import NotificationChannel +from app.models.notification_policy import POLICY_ROW_ID, NotificationPolicy +from app.services.notification_settings import ( + CHANNEL_EMAIL, + CHANNEL_TELEGRAM, + CHANNEL_WEBHOOK, + VALID_SEVERITIES, +) + +DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL}) + + +@dataclass(frozen=True) +class NotificationPolicyConfig: + min_severity: str + use_telegram: bool + use_webhook: bool + use_email: bool + source: str # env | db + + def allows_channel(self, channel: str) -> bool: + if channel == CHANNEL_TELEGRAM: + return self.use_telegram + if channel == CHANNEL_WEBHOOK: + return self.use_webhook + if channel == CHANNEL_EMAIL: + return self.use_email + return False + + +def _parse_channels_csv(value: str) -> tuple[bool, bool, bool]: + parts = {p.strip().lower() for p in value.split(",") if p.strip()} + return ( + CHANNEL_TELEGRAM in parts or "tg" in parts, + CHANNEL_WEBHOOK in parts, + CHANNEL_EMAIL in parts or "mail" in parts or "smtp" in parts, + ) + + +def _policy_from_env() -> NotificationPolicyConfig: + settings = get_settings() + use_tg, use_wh, use_em = _parse_channels_csv(settings.notify_channels) + min_sev = settings.notify_min_severity.strip() or "warning" + if min_sev not in VALID_SEVERITIES: + min_sev = "warning" + return NotificationPolicyConfig( + min_severity=min_sev, + use_telegram=use_tg, + use_webhook=use_wh, + use_email=use_em, + source="env", + ) + + +def get_effective_notification_policy(db: Session | None = None) -> NotificationPolicyConfig: + if db is None: + from app.database import SessionLocal + + session = SessionLocal() + try: + return get_effective_notification_policy(session) + finally: + session.close() + + row = db.get(NotificationPolicy, POLICY_ROW_ID) + env_cfg = _policy_from_env() + if row is None: + return env_cfg + + min_sev = (row.min_severity or "").strip() or env_cfg.min_severity + if min_sev not in VALID_SEVERITIES: + min_sev = env_cfg.min_severity + + return NotificationPolicyConfig( + min_severity=min_sev, + use_telegram=bool(row.use_telegram), + use_webhook=bool(row.use_webhook), + use_email=bool(row.use_email), + source="db", + ) + + +def _sync_channel_min_severity(db: Session, min_severity: str) -> None: + for channel in (CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL): + row = db.get(NotificationChannel, channel) + if row is not None: + row.min_severity = min_severity + + +def upsert_notification_policy( + db: Session, + *, + min_severity: str, + use_telegram: bool, + use_webhook: bool, + use_email: bool, +) -> NotificationPolicyConfig: + if min_severity not in VALID_SEVERITIES: + raise ValueError(f"invalid min_severity: {min_severity}") + + row = db.get(NotificationPolicy, POLICY_ROW_ID) + if row is None: + row = NotificationPolicy( + id=POLICY_ROW_ID, + min_severity=min_severity, + use_telegram=use_telegram, + use_webhook=use_webhook, + use_email=use_email, + ) + db.add(row) + else: + row.min_severity = min_severity + row.use_telegram = use_telegram + row.use_webhook = use_webhook + row.use_email = use_email + + _sync_channel_min_severity(db, min_severity) + db.commit() + db.refresh(row) + return get_effective_notification_policy(db) diff --git a/backend/app/services/notification_severity.py b/backend/app/services/notification_severity.py new file mode 100644 index 0000000..9805f18 --- /dev/null +++ b/backend/app/services/notification_severity.py @@ -0,0 +1,11 @@ +"""Severity ordering for notification policy gates.""" + +SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40} + + +def severity_value(value: str) -> int: + return SEVERITY_ORDER.get(value, 0) + + +def severity_meets_minimum(severity: str, minimum: str) -> bool: + return severity_value(severity) >= severity_value(minimum) diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py index 6b88645..22e4fa6 100644 --- a/backend/app/services/notify_dispatch.py +++ b/backend/app/services/notify_dispatch.py @@ -1,18 +1,33 @@ -"""Dispatch ingest notifications to all configured channels.""" +"""Dispatch ingest notifications per global policy (severity → channels).""" from sqlalchemy.orm import Session from app.models import Event, Problem from app.services import email_notify, telegram_notify, webhook_notify +from app.services.notification_policy import get_effective_notification_policy +from app.services.notification_severity import severity_meets_minimum +from app.services.notification_settings import CHANNEL_EMAIL, CHANNEL_TELEGRAM, CHANNEL_WEBHOOK def notify_event(event: Event, *, db: Session | None = None) -> None: - telegram_notify.notify_event(event, db=db) - webhook_notify.notify_event(event, db=db) - email_notify.notify_event(event, db=db) + policy = get_effective_notification_policy(db) + if not severity_meets_minimum(event.severity, policy.min_severity): + return + if policy.use_telegram: + telegram_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_webhook: + webhook_notify.notify_event(event, db=db, apply_policy_gate=False) + if policy.use_email: + email_notify.notify_event(event, db=db, apply_policy_gate=False) def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: - telegram_notify.notify_problem(problem, event, db=db) - webhook_notify.notify_problem(problem, event, db=db) - email_notify.notify_problem(problem, event, db=db) + policy = get_effective_notification_policy(db) + if not severity_meets_minimum(problem.severity, policy.min_severity): + return + if policy.use_telegram: + telegram_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) + if policy.use_webhook: + webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) + if policy.use_email: + email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False) diff --git a/backend/app/services/telegram_notify.py b/backend/app/services/telegram_notify.py index 63ea944..59f9b34 100644 --- a/backend/app/services/telegram_notify.py +++ b/backend/app/services/telegram_notify.py @@ -4,12 +4,11 @@ import httpx from sqlalchemy.orm import Session from app.models import Event, Problem +from app.services.notification_severity import severity_meets_minimum from app.services.notification_settings import TelegramConfig, get_effective_telegram_config logger = logging.getLogger(__name__) -SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40} - class TelegramNotConfiguredError(Exception): """Telegram disabled or missing token/chat_id.""" @@ -21,10 +20,6 @@ class TelegramSendError(Exception): self.status_code = status_code -def _severity_value(value: str) -> int: - return SEVERITY_ORDER.get(value, 0) - - def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None: cfg = config or get_effective_telegram_config() if not force and not cfg.active: @@ -61,14 +56,11 @@ def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None: ) -def _should_notify_severity(severity: str, *, config: TelegramConfig) -> bool: - min_level = _severity_value(config.min_severity) - return _severity_value(severity) >= min_level - - -def notify_event(event: Event, *, db: Session | None = None) -> None: +def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: cfg = get_effective_telegram_config(db) - if not cfg.active or not _should_notify_severity(event.severity, config=cfg): + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity): return host = event.host.hostname if event.host else "unknown" message = ( @@ -85,9 +77,11 @@ def notify_event(event: Event, *, db: Session | None = None) -> None: logger.exception("notify_event telegram failed event_id=%s", event.event_id) -def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: cfg = get_effective_telegram_config(db) - if not cfg.active or not _should_notify_severity(problem.severity, config=cfg): + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity): return host = problem.host.hostname if problem.host else "unknown" related = "" diff --git a/backend/app/services/webhook_notify.py b/backend/app/services/webhook_notify.py index cf508f1..c877ead 100644 --- a/backend/app/services/webhook_notify.py +++ b/backend/app/services/webhook_notify.py @@ -9,8 +9,8 @@ import httpx from sqlalchemy.orm import Session from app.models import Event, Problem +from app.services.notification_severity import severity_meets_minimum from app.services.notification_settings import WebhookConfig, get_effective_webhook_config -from app.services.telegram_notify import SEVERITY_ORDER logger = logging.getLogger(__name__) @@ -25,14 +25,6 @@ class WebhookSendError(Exception): self.status_code = status_code -def _severity_value(value: str) -> int: - return SEVERITY_ORDER.get(value, 0) - - -def _should_notify_severity(severity: str, *, config: WebhookConfig) -> bool: - return _severity_value(severity) >= _severity_value(config.min_severity) - - def _host_payload(entity: Event | Problem) -> dict[str, Any]: host = entity.host if host is None: @@ -120,9 +112,11 @@ def send_webhook_test_message(*, config: WebhookConfig | None = None) -> None: ) -def notify_event(event: Event, *, db: Session | None = None) -> None: +def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: cfg = get_effective_webhook_config(db) - if not cfg.active or not _should_notify_severity(event.severity, config=cfg): + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity): return try: send_webhook_payload(build_event_payload(event), config=cfg) @@ -130,9 +124,11 @@ def notify_event(event: Event, *, db: Session | None = None) -> None: logger.exception("notify_event webhook failed event_id=%s", event.event_id) -def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None: cfg = get_effective_webhook_config(db) - if not cfg.active or not _should_notify_severity(problem.severity, config=cfg): + if not cfg.active: + return + if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity): return try: send_webhook_payload(build_problem_payload(problem, event), config=cfg) diff --git a/backend/tests/test_notification_policy.py b/backend/tests/test_notification_policy.py new file mode 100644 index 0000000..fe2aef7 --- /dev/null +++ b/backend/tests/test_notification_policy.py @@ -0,0 +1,63 @@ +"""Global notification policy (notif-22).""" + +from app.config import get_settings +from app.models.notification_policy import NotificationPolicy +from app.services.notification_policy import get_effective_notification_policy, upsert_notification_policy +from app.services.notification_severity import severity_meets_minimum + + +def test_severity_meets_minimum(): + assert severity_meets_minimum("warning", "warning") + assert severity_meets_minimum("high", "warning") + assert not severity_meets_minimum("info", "warning") + + +def test_put_policy_persists_db(jwt_headers, client, db_session, monkeypatch): + monkeypatch.setenv("NOTIFY_MIN_SEVERITY", "high") + monkeypatch.setenv("NOTIFY_CHANNELS", "telegram") + get_settings.cache_clear() + + response = client.put( + "/api/v1/settings/notifications/policy", + headers=jwt_headers, + json={ + "min_severity": "warning", + "use_telegram": True, + "use_webhook": True, + "use_email": False, + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["min_severity"] == "warning" + assert body["use_webhook"] is True + assert body["source"] == "db" + + row = db_session.get(NotificationPolicy, 1) + assert row is not None + assert row.use_webhook is True + + cfg = get_effective_notification_policy(db_session) + assert cfg.min_severity == "warning" + + +def test_upsert_policy_syncs_channel_min_severity(db_session, monkeypatch): + monkeypatch.setenv("NOTIFY_MIN_SEVERITY", "high") + get_settings.cache_clear() + + from app.models.notification_channel import NotificationChannel + + db_session.add( + NotificationChannel(channel="telegram", enabled=False, min_severity="high") + ) + db_session.commit() + + upsert_notification_policy( + db_session, + min_severity="critical", + use_telegram=True, + use_webhook=False, + use_email=False, + ) + row = db_session.get(NotificationChannel, "telegram") + assert row.min_severity == "critical" diff --git a/backend/tests/test_notify_dispatch.py b/backend/tests/test_notify_dispatch.py new file mode 100644 index 0000000..c92ce62 --- /dev/null +++ b/backend/tests/test_notify_dispatch.py @@ -0,0 +1,62 @@ +"""notify_dispatch respects global policy.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +from app.models import Event +from app.services import notify_dispatch +from app.services.notification_policy import NotificationPolicyConfig + + +def test_notify_event_skipped_below_policy_severity(): + event = Event( + event_id="00000000-0000-4000-8000-000000000401", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="skip", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=False, + use_email=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_not_called() + + +def test_notify_event_calls_selected_channels(): + event = Event( + event_id="00000000-0000-4000-8000-000000000402", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.failed", + severity="warning", + title="fail", + summary="send", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="warning", + use_telegram=True, + use_webhook=True, + use_email=False, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + 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: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_called_once() + mock_wh.notify_event.assert_called_once() + mock_em.notify_event.assert_not_called() diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py index 1739dc7..d47d36f 100644 --- a/backend/tests/test_settings_api.py +++ b/backend/tests/test_settings_api.py @@ -22,6 +22,8 @@ def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatc assert body["telegram"]["min_severity"] == "warning" assert body["telegram"]["source"] == "env" assert body["telegram"]["bot_token_hint"].endswith("ghij") + assert "policy" in body + assert body["policy"]["min_severity"] == "warning" assert "webhook" in body assert body["webhook"]["enabled"] is False assert "email" in body @@ -38,7 +40,6 @@ def test_put_email_settings_persists_db(jwt_headers, client, db_session, monkeyp headers=jwt_headers, json={ "enabled": True, - "min_severity": "warning", "smtp_host": "smtp.example.com", "smtp_port": 587, "smtp_user": "monitor", @@ -95,7 +96,6 @@ def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monke headers=jwt_headers, json={ "enabled": True, - "min_severity": "warning", "url": "https://example.com/hooks/sac", "secret_header": "X-SAC-Token", "secret": "supersecret", @@ -145,7 +145,6 @@ def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monk headers=jwt_headers, json={ "enabled": True, - "min_severity": "warning", "bot_token": "1234567890:TESTTOKEN", "chat_id": "999001", }, @@ -154,7 +153,6 @@ def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monk body = response.json() assert body["source"] == "db" assert body["enabled"] is True - assert body["min_severity"] == "warning" row = db_session.get(NotificationChannel, "telegram") assert row is not None @@ -163,7 +161,6 @@ def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monk cfg = get_effective_telegram_config(db_session) assert cfg.source == "db" - assert cfg.min_severity == "warning" def test_post_telegram_test_success(jwt_headers, client, db_session, monkeypatch): diff --git a/deploy/env.native.example b/deploy/env.native.example index a08e050..fda4691 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -44,6 +44,10 @@ SMTP_STARTTLS=true SMTP_SSL=false SMTP_MIN_SEVERITY=high +# Глобальное правило: severity ≥ NOTIFY_MIN_SEVERITY → каналы из NOTIFY_CHANNELS +NOTIFY_MIN_SEVERITY=warning +NOTIFY_CHANNELS=telegram,webhook,email + # Статус хоста по agent.heartbeat SAC_HEARTBEAT_STALE_MINUTES=780 diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 81590b9..25956db 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -50,25 +50,25 @@ $SacSpoolDir = "D:\Soft\Logs\sac-spool" При **`exclusive`** агент **не** шлёт Telegram/email — оператору нужны оповещения **из SAC** (`backend/app/services/telegram_notify.py`). -На сервере SAC в **`/opt/security-alert-center/config/sac-api.env`** (см. `deploy/env.native.example`): +На сервере SAC — **`sac-api.env`** или UI **Настройки** → **Правило оповещений**: один порог severity и выбор каналов (Telegram / webhook / email). ```env +NOTIFY_MIN_SEVERITY=warning +NOTIFY_CHANNELS=telegram,webhook,email TELEGRAM_ENABLED=true TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= -# warning — failed login, sudo, problems; high — только high/critical -TELEGRAM_MIN_SEVERITY=warning ``` -| Severity события | `warning` | `high` (по умолчанию в example) | -|------------------|-----------|----------------------------------| +| Severity события | `NOTIFY_MIN_SEVERITY=warning` | `high` | +|------------------|-------------------------------|--------| | `info` (успешный RDP/SSH login) | нет | нет | | `warning` (`*.login.failed`, sudo) | **да** | нет | | `high` / `critical` (ban, problem) | **да** | **да** | -Problems (`notify_problem`) учитывают тот же порог `TELEGRAM_MIN_SEVERITY`. +Events и problems используют **одно** глобальное правило (`notification_policy` в БД, миграция `007`). -UI **Настройки** (`/settings`) — просмотр и редактирование Telegram (JWT admin): значения в БД `notification_channels` с fallback на `sac-api.env`, пока запись не создана. После деплоя выполнить `alembic upgrade head` на сервере SAC. +UI **Настройки** (`/settings`) — правило + параметры каналов (JWT admin). После деплоя: `alembic upgrade head`. ### 1.4. Версии и доставка обновлений diff --git a/docs/operations-prod-status.md b/docs/operations-prod-status.md index c45364f..98e4072 100644 --- a/docs/operations-prod-status.md +++ b/docs/operations-prod-status.md @@ -50,7 +50,7 @@ sudo /opt/sac-deploy.sh ## Следующие шаги (2026-05-29) -1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (миграции **`004`–`006`**: Telegram, webhook, SMTP) +1. **Деплой SAC v0.5.0** — `sudo /opt/sac-deploy.sh` (миграции **`004`–`007`**: каналы + глобальное правило) 2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod 3. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram» 4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md index b4ac358..7400ed2 100644 --- a/docs/todo-2026-05-29.md +++ b/docs/todo-2026-05-29.md @@ -46,7 +46,7 @@ |----|--------|----------| | `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | ✅ PUT/test API + UI + ingest | | `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | ✅ PUT/test API + UI + ingest | -| `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | Одна строка в настройках | +| `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | ✅ `notification_policy` + UI «Правило оповещений» | ### P3 — Качество сообщений (можно сдвинуть) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 912ba62..6eb832f 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -11,6 +11,48 @@

Загрузка…