feat: global notification policy severity to channels (notif-22)
Singleton notification_policy table, NOTIFY_MIN_SEVERITY/CHANNELS env, central dispatch gate, Settings policy UI, migration 007. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -5,6 +5,11 @@ from sqlalchemy.orm import Session
|
|||||||
from app.auth.jwt_auth import get_current_user
|
from app.auth.jwt_auth import get_current_user
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
|
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 (
|
from app.services.notification_settings import (
|
||||||
VALID_SEVERITIES,
|
VALID_SEVERITIES,
|
||||||
EmailConfig,
|
EmailConfig,
|
||||||
@@ -75,22 +80,36 @@ class EmailSettingsResponse(BaseModel):
|
|||||||
source: str = Field(description="env или db")
|
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):
|
class NotificationSettingsResponse(BaseModel):
|
||||||
|
policy: NotificationPolicyResponse
|
||||||
telegram: TelegramSettingsResponse
|
telegram: TelegramSettingsResponse
|
||||||
webhook: WebhookSettingsResponse
|
webhook: WebhookSettingsResponse
|
||||||
email: EmailSettingsResponse
|
email: EmailSettingsResponse
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPolicyUpdate(BaseModel):
|
||||||
|
min_severity: str
|
||||||
|
use_telegram: bool
|
||||||
|
use_webhook: bool
|
||||||
|
use_email: bool
|
||||||
|
|
||||||
|
|
||||||
class TelegramSettingsUpdate(BaseModel):
|
class TelegramSettingsUpdate(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
min_severity: str
|
|
||||||
bot_token: str | None = None
|
bot_token: str | None = None
|
||||||
chat_id: str | None = None
|
chat_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WebhookSettingsUpdate(BaseModel):
|
class WebhookSettingsUpdate(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
min_severity: str
|
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
secret_header: str | None = None
|
secret_header: str | None = None
|
||||||
secret: str | None = None
|
secret: str | None = None
|
||||||
@@ -98,7 +117,6 @@ class WebhookSettingsUpdate(BaseModel):
|
|||||||
|
|
||||||
class EmailSettingsUpdate(BaseModel):
|
class EmailSettingsUpdate(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
min_severity: str
|
|
||||||
smtp_host: str | None = None
|
smtp_host: str | None = None
|
||||||
smtp_port: int | None = None
|
smtp_port: int | None = None
|
||||||
smtp_user: str | None = None
|
smtp_user: str | None = None
|
||||||
@@ -114,30 +132,40 @@ class ChannelTestResponse(BaseModel):
|
|||||||
message: str
|
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(
|
return TelegramSettingsResponse(
|
||||||
enabled=cfg.enabled,
|
enabled=cfg.enabled,
|
||||||
configured=cfg.configured,
|
configured=cfg.configured,
|
||||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||||
min_severity=cfg.min_severity,
|
min_severity=policy.min_severity,
|
||||||
source=cfg.source,
|
source=cfg.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
|
def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse:
|
||||||
return WebhookSettingsResponse(
|
return WebhookSettingsResponse(
|
||||||
enabled=cfg.enabled,
|
enabled=cfg.enabled,
|
||||||
configured=cfg.configured,
|
configured=cfg.configured,
|
||||||
url_hint=_mask_url(cfg.url),
|
url_hint=_mask_url(cfg.url),
|
||||||
secret_header=cfg.secret_header or None,
|
secret_header=cfg.secret_header or None,
|
||||||
secret_hint=_mask_secret(cfg.secret),
|
secret_hint=_mask_secret(cfg.secret),
|
||||||
min_severity=cfg.min_severity,
|
min_severity=policy.min_severity,
|
||||||
source=cfg.source,
|
source=cfg.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _email_to_response(cfg: EmailConfig) -> EmailSettingsResponse:
|
def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse:
|
||||||
return EmailSettingsResponse(
|
return EmailSettingsResponse(
|
||||||
enabled=cfg.enabled,
|
enabled=cfg.enabled,
|
||||||
configured=cfg.configured,
|
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,
|
mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None,
|
||||||
smtp_starttls=cfg.smtp_starttls,
|
smtp_starttls=cfg.smtp_starttls,
|
||||||
smtp_ssl=cfg.smtp_ssl,
|
smtp_ssl=cfg.smtp_ssl,
|
||||||
min_severity=cfg.min_severity,
|
min_severity=policy.min_severity,
|
||||||
source=cfg.source,
|
source=cfg.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,32 +187,55 @@ def get_notification_settings(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> NotificationSettingsResponse:
|
) -> NotificationSettingsResponse:
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
return NotificationSettingsResponse(
|
return NotificationSettingsResponse(
|
||||||
telegram=_telegram_to_response(get_effective_telegram_config(db)),
|
policy=_policy_to_response(policy),
|
||||||
webhook=_webhook_to_response(get_effective_webhook_config(db)),
|
telegram=_telegram_to_response(get_effective_telegram_config(db), policy),
|
||||||
email=_email_to_response(get_effective_email_config(db)),
|
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)
|
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
|
||||||
def update_telegram_settings(
|
def update_telegram_settings(
|
||||||
body: TelegramSettingsUpdate,
|
body: TelegramSettingsUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> TelegramSettingsResponse:
|
) -> 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:
|
try:
|
||||||
cfg = upsert_telegram_channel(
|
cfg = upsert_telegram_channel(
|
||||||
db,
|
db,
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
min_severity=body.min_severity,
|
|
||||||
bot_token=body.bot_token,
|
bot_token=body.bot_token,
|
||||||
chat_id=body.chat_id,
|
chat_id=body.chat_id,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from 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)
|
@router.put("/notifications/webhook", response_model=WebhookSettingsResponse)
|
||||||
@@ -193,20 +244,18 @@ def update_webhook_settings(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> WebhookSettingsResponse:
|
) -> 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:
|
try:
|
||||||
cfg = upsert_webhook_channel(
|
cfg = upsert_webhook_channel(
|
||||||
db,
|
db,
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
min_severity=body.min_severity,
|
|
||||||
url=body.url,
|
url=body.url,
|
||||||
secret_header=body.secret_header,
|
secret_header=body.secret_header,
|
||||||
secret=body.secret,
|
secret=body.secret,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from 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)
|
@router.put("/notifications/email", response_model=EmailSettingsResponse)
|
||||||
@@ -215,13 +264,10 @@ def update_email_settings(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
) -> EmailSettingsResponse:
|
) -> 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:
|
try:
|
||||||
cfg = upsert_email_channel(
|
cfg = upsert_email_channel(
|
||||||
db,
|
db,
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
min_severity=body.min_severity,
|
|
||||||
smtp_host=body.smtp_host,
|
smtp_host=body.smtp_host,
|
||||||
smtp_port=body.smtp_port,
|
smtp_port=body.smtp_port,
|
||||||
smtp_user=body.smtp_user,
|
smtp_user=body.smtp_user,
|
||||||
@@ -233,7 +279,8 @@ def update_email_settings(
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from 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)
|
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
|
|||||||
smtp_ssl: bool = False
|
smtp_ssl: bool = False
|
||||||
smtp_min_severity: str = "high"
|
smtp_min_severity: str = "high"
|
||||||
|
|
||||||
|
notify_min_severity: str = "warning"
|
||||||
|
notify_channels: str = "telegram,webhook,email"
|
||||||
|
|
||||||
# Порог «живости» агента
|
# Порог «живости» агента
|
||||||
sac_heartbeat_stale_minutes: int = 780
|
sac_heartbeat_stale_minutes: int = 780
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from app.models.api_key import ApiKey
|
|||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.models.host import Host
|
from app.models.host import Host
|
||||||
from app.models.notification_channel import NotificationChannel
|
from app.models.notification_channel import NotificationChannel
|
||||||
|
from app.models.notification_policy import NotificationPolicy
|
||||||
from app.models.problem import Problem, ProblemEvent
|
from app.models.problem import Problem, ProblemEvent
|
||||||
|
|
||||||
__all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "Problem", "ProblemEvent"]
|
__all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "NotificationPolicy", "Problem", "ProblemEvent"]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -10,8 +10,8 @@ from email.mime.text import MIMEText
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Problem
|
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.notification_settings import EmailConfig, get_effective_email_config
|
||||||
from app.services.telegram_notify import SEVERITY_ORDER
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -24,14 +24,6 @@ class EmailSendError(Exception):
|
|||||||
pass
|
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]:
|
def parse_mail_recipients(mail_to: str) -> list[str]:
|
||||||
return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()]
|
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)
|
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
|
return
|
||||||
host = event.host.hostname if event.host else "unknown"
|
host = event.host.hostname if event.host else "unknown"
|
||||||
body = (
|
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)
|
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)
|
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
|
return
|
||||||
host = problem.host.hostname if problem.host else "unknown"
|
host = problem.host.hostname if problem.host else "unknown"
|
||||||
related = ""
|
related = ""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Problem
|
from app.models import Event, Problem
|
||||||
from app.services import email_notify, telegram_notify, webhook_notify
|
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:
|
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||||
telegram_notify.notify_event(event, db=db)
|
policy = get_effective_notification_policy(db)
|
||||||
webhook_notify.notify_event(event, db=db)
|
if not severity_meets_minimum(event.severity, policy.min_severity):
|
||||||
email_notify.notify_event(event, db=db)
|
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:
|
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
|
||||||
telegram_notify.notify_problem(problem, event, db=db)
|
policy = get_effective_notification_policy(db)
|
||||||
webhook_notify.notify_problem(problem, event, db=db)
|
if not severity_meets_minimum(problem.severity, policy.min_severity):
|
||||||
email_notify.notify_problem(problem, event, db=db)
|
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)
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import httpx
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Problem
|
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
|
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40}
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramNotConfiguredError(Exception):
|
class TelegramNotConfiguredError(Exception):
|
||||||
"""Telegram disabled or missing token/chat_id."""
|
"""Telegram disabled or missing token/chat_id."""
|
||||||
@@ -21,10 +20,6 @@ class TelegramSendError(Exception):
|
|||||||
self.status_code = status_code
|
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:
|
def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None:
|
||||||
cfg = config or get_effective_telegram_config()
|
cfg = config or get_effective_telegram_config()
|
||||||
if not force and not cfg.active:
|
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:
|
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||||
min_level = _severity_value(config.min_severity)
|
|
||||||
return _severity_value(severity) >= min_level
|
|
||||||
|
|
||||||
|
|
||||||
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
|
||||||
cfg = get_effective_telegram_config(db)
|
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
|
return
|
||||||
host = event.host.hostname if event.host else "unknown"
|
host = event.host.hostname if event.host else "unknown"
|
||||||
message = (
|
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)
|
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)
|
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
|
return
|
||||||
host = problem.host.hostname if problem.host else "unknown"
|
host = problem.host.hostname if problem.host else "unknown"
|
||||||
related = ""
|
related = ""
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import httpx
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Problem
|
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.notification_settings import WebhookConfig, get_effective_webhook_config
|
||||||
from app.services.telegram_notify import SEVERITY_ORDER
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,14 +25,6 @@ class WebhookSendError(Exception):
|
|||||||
self.status_code = status_code
|
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]:
|
def _host_payload(entity: Event | Problem) -> dict[str, Any]:
|
||||||
host = entity.host
|
host = entity.host
|
||||||
if host is None:
|
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)
|
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
|
return
|
||||||
try:
|
try:
|
||||||
send_webhook_payload(build_event_payload(event), config=cfg)
|
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)
|
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)
|
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
|
return
|
||||||
try:
|
try:
|
||||||
send_webhook_payload(build_problem_payload(problem, event), config=cfg)
|
send_webhook_payload(build_problem_payload(problem, event), config=cfg)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -22,6 +22,8 @@ def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatc
|
|||||||
assert body["telegram"]["min_severity"] == "warning"
|
assert body["telegram"]["min_severity"] == "warning"
|
||||||
assert body["telegram"]["source"] == "env"
|
assert body["telegram"]["source"] == "env"
|
||||||
assert body["telegram"]["bot_token_hint"].endswith("ghij")
|
assert body["telegram"]["bot_token_hint"].endswith("ghij")
|
||||||
|
assert "policy" in body
|
||||||
|
assert body["policy"]["min_severity"] == "warning"
|
||||||
assert "webhook" in body
|
assert "webhook" in body
|
||||||
assert body["webhook"]["enabled"] is False
|
assert body["webhook"]["enabled"] is False
|
||||||
assert "email" in body
|
assert "email" in body
|
||||||
@@ -38,7 +40,6 @@ def test_put_email_settings_persists_db(jwt_headers, client, db_session, monkeyp
|
|||||||
headers=jwt_headers,
|
headers=jwt_headers,
|
||||||
json={
|
json={
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"min_severity": "warning",
|
|
||||||
"smtp_host": "smtp.example.com",
|
"smtp_host": "smtp.example.com",
|
||||||
"smtp_port": 587,
|
"smtp_port": 587,
|
||||||
"smtp_user": "monitor",
|
"smtp_user": "monitor",
|
||||||
@@ -95,7 +96,6 @@ def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monke
|
|||||||
headers=jwt_headers,
|
headers=jwt_headers,
|
||||||
json={
|
json={
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"min_severity": "warning",
|
|
||||||
"url": "https://example.com/hooks/sac",
|
"url": "https://example.com/hooks/sac",
|
||||||
"secret_header": "X-SAC-Token",
|
"secret_header": "X-SAC-Token",
|
||||||
"secret": "supersecret",
|
"secret": "supersecret",
|
||||||
@@ -145,7 +145,6 @@ def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monk
|
|||||||
headers=jwt_headers,
|
headers=jwt_headers,
|
||||||
json={
|
json={
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"min_severity": "warning",
|
|
||||||
"bot_token": "1234567890:TESTTOKEN",
|
"bot_token": "1234567890:TESTTOKEN",
|
||||||
"chat_id": "999001",
|
"chat_id": "999001",
|
||||||
},
|
},
|
||||||
@@ -154,7 +153,6 @@ def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monk
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["source"] == "db"
|
assert body["source"] == "db"
|
||||||
assert body["enabled"] is True
|
assert body["enabled"] is True
|
||||||
assert body["min_severity"] == "warning"
|
|
||||||
|
|
||||||
row = db_session.get(NotificationChannel, "telegram")
|
row = db_session.get(NotificationChannel, "telegram")
|
||||||
assert row is not None
|
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)
|
cfg = get_effective_telegram_config(db_session)
|
||||||
assert cfg.source == "db"
|
assert cfg.source == "db"
|
||||||
assert cfg.min_severity == "warning"
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_telegram_test_success(jwt_headers, client, db_session, monkeypatch):
|
def test_post_telegram_test_success(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ SMTP_STARTTLS=true
|
|||||||
SMTP_SSL=false
|
SMTP_SSL=false
|
||||||
SMTP_MIN_SEVERITY=high
|
SMTP_MIN_SEVERITY=high
|
||||||
|
|
||||||
|
# Глобальное правило: severity ≥ NOTIFY_MIN_SEVERITY → каналы из NOTIFY_CHANNELS
|
||||||
|
NOTIFY_MIN_SEVERITY=warning
|
||||||
|
NOTIFY_CHANNELS=telegram,webhook,email
|
||||||
|
|
||||||
# Статус хоста по agent.heartbeat
|
# Статус хоста по agent.heartbeat
|
||||||
SAC_HEARTBEAT_STALE_MINUTES=780
|
SAC_HEARTBEAT_STALE_MINUTES=780
|
||||||
|
|
||||||
|
|||||||
@@ -50,25 +50,25 @@ $SacSpoolDir = "D:\Soft\Logs\sac-spool"
|
|||||||
|
|
||||||
При **`exclusive`** агент **не** шлёт Telegram/email — оператору нужны оповещения **из SAC** (`backend/app/services/telegram_notify.py`).
|
При **`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
|
```env
|
||||||
|
NOTIFY_MIN_SEVERITY=warning
|
||||||
|
NOTIFY_CHANNELS=telegram,webhook,email
|
||||||
TELEGRAM_ENABLED=true
|
TELEGRAM_ENABLED=true
|
||||||
TELEGRAM_BOT_TOKEN=<bot>
|
TELEGRAM_BOT_TOKEN=<bot>
|
||||||
TELEGRAM_CHAT_ID=<chat>
|
TELEGRAM_CHAT_ID=<chat>
|
||||||
# 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) | нет | нет |
|
| `info` (успешный RDP/SSH login) | нет | нет |
|
||||||
| `warning` (`*.login.failed`, sudo) | **да** | нет |
|
| `warning` (`*.login.failed`, sudo) | **да** | нет |
|
||||||
| `high` / `critical` (ban, problem) | **да** | **да** |
|
| `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. Версии и доставка обновлений
|
### 1.4. Версии и доставка обновлений
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ sudo /opt/sac-deploy.sh
|
|||||||
|
|
||||||
## Следующие шаги (2026-05-29)
|
## Следующие шаги (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
|
2. **notif-02** — прогон [testing-e2e-checklist.md](testing-e2e-checklist.md) § «SAC Telegram» на prod
|
||||||
3. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram»
|
3. **Настройки UI** — `/settings`: Telegram `warning+`, кнопка «Проверить Telegram»
|
||||||
4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах
|
4. **RDP 1.2.20-SAC** — NETLOGON + `Deploy-LoginMonitor.ps1` на пилотных хостах
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|----|--------|----------|
|
|----|--------|----------|
|
||||||
| `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | ✅ PUT/test API + UI + ingest |
|
| `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-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 — Качество сообщений (можно сдвинуть)
|
### P3 — Качество сообщений (можно сдвинуть)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,48 @@
|
|||||||
<p v-else-if="loading">Загрузка…</p>
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<section class="card settings-card settings-policy">
|
||||||
|
<h2>Правило оповещений</h2>
|
||||||
|
<p class="settings-hint settings-hint-top">
|
||||||
|
События и problems с severity <strong>≥</strong> порога уходят в выбранные каналы (если канал включён и
|
||||||
|
настроен).
|
||||||
|
</p>
|
||||||
|
<form class="settings-form" @submit.prevent="savePolicy">
|
||||||
|
<label class="settings-field">
|
||||||
|
Мин. severity
|
||||||
|
<select v-model="policyForm.min_severity">
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset class="settings-channels">
|
||||||
|
<legend>Каналы</legend>
|
||||||
|
<label class="settings-inline">
|
||||||
|
<input v-model="policyForm.use_telegram" type="checkbox" />
|
||||||
|
Telegram
|
||||||
|
</label>
|
||||||
|
<label class="settings-inline">
|
||||||
|
<input v-model="policyForm.use_webhook" type="checkbox" />
|
||||||
|
Webhook
|
||||||
|
</label>
|
||||||
|
<label class="settings-inline">
|
||||||
|
<input v-model="policyForm.use_email" type="checkbox" />
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<p v-if="policyLoaded" class="settings-meta">
|
||||||
|
Источник: <code>{{ policyLoaded.source }}</code>
|
||||||
|
</p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="submit" :disabled="savingPolicy">
|
||||||
|
{{ savingPolicy ? "Сохранение…" : "Сохранить правило" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card settings-card">
|
<section class="card settings-card">
|
||||||
<h2>Telegram</h2>
|
<h2>Telegram</h2>
|
||||||
<form class="settings-form" @submit.prevent="saveTelegram">
|
<form class="settings-form" @submit.prevent="saveTelegram">
|
||||||
@@ -18,15 +60,6 @@
|
|||||||
<input v-model="telegramForm.enabled" type="checkbox" />
|
<input v-model="telegramForm.enabled" type="checkbox" />
|
||||||
Включить Telegram
|
Включить Telegram
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
|
||||||
Мин. severity
|
|
||||||
<select v-model="telegramForm.min_severity">
|
|
||||||
<option value="info">info</option>
|
|
||||||
<option value="warning">warning</option>
|
|
||||||
<option value="high">high</option>
|
|
||||||
<option value="critical">critical</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
Bot token
|
Bot token
|
||||||
<input
|
<input
|
||||||
@@ -71,15 +104,6 @@
|
|||||||
<input v-model="webhookForm.enabled" type="checkbox" />
|
<input v-model="webhookForm.enabled" type="checkbox" />
|
||||||
Включить webhook
|
Включить webhook
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
|
||||||
Мин. severity
|
|
||||||
<select v-model="webhookForm.min_severity">
|
|
||||||
<option value="info">info</option>
|
|
||||||
<option value="warning">warning</option>
|
|
||||||
<option value="high">high</option>
|
|
||||||
<option value="critical">critical</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
URL
|
URL
|
||||||
<input
|
<input
|
||||||
@@ -125,15 +149,6 @@
|
|||||||
<input v-model="emailForm.enabled" type="checkbox" />
|
<input v-model="emailForm.enabled" type="checkbox" />
|
||||||
Включить email
|
Включить email
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
|
||||||
Мин. severity
|
|
||||||
<select v-model="emailForm.min_severity">
|
|
||||||
<option value="info">info</option>
|
|
||||||
<option value="warning">warning</option>
|
|
||||||
<option value="high">high</option>
|
|
||||||
<option value="critical">critical</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
SMTP host
|
SMTP host
|
||||||
<input v-model="emailForm.smtp_host" type="text" autocomplete="off" :placeholder="smtpHostPlaceholder" />
|
<input v-model="emailForm.smtp_host" type="text" autocomplete="off" :placeholder="smtpHostPlaceholder" />
|
||||||
@@ -227,7 +242,16 @@ interface EmailSettings {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationPolicy {
|
||||||
|
min_severity: string;
|
||||||
|
use_telegram: boolean;
|
||||||
|
use_webhook: boolean;
|
||||||
|
use_email: boolean;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const savingPolicy = ref(false);
|
||||||
const savingTelegram = ref(false);
|
const savingTelegram = ref(false);
|
||||||
const savingWebhook = ref(false);
|
const savingWebhook = ref(false);
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
@@ -239,17 +263,23 @@ const success = ref("");
|
|||||||
const telegramLoaded = ref<TelegramSettings | null>(null);
|
const telegramLoaded = ref<TelegramSettings | null>(null);
|
||||||
const webhookLoaded = ref<WebhookSettings | null>(null);
|
const webhookLoaded = ref<WebhookSettings | null>(null);
|
||||||
const emailLoaded = ref<EmailSettings | null>(null);
|
const emailLoaded = ref<EmailSettings | null>(null);
|
||||||
|
const policyLoaded = ref<NotificationPolicy | null>(null);
|
||||||
|
|
||||||
|
const policyForm = reactive({
|
||||||
|
min_severity: "warning",
|
||||||
|
use_telegram: true,
|
||||||
|
use_webhook: false,
|
||||||
|
use_email: false,
|
||||||
|
});
|
||||||
|
|
||||||
const telegramForm = reactive({
|
const telegramForm = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
min_severity: "warning",
|
|
||||||
bot_token: "",
|
bot_token: "",
|
||||||
chat_id: "",
|
chat_id: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const webhookForm = reactive({
|
const webhookForm = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
min_severity: "warning",
|
|
||||||
url: "",
|
url: "",
|
||||||
secret_header: "",
|
secret_header: "",
|
||||||
secret: "",
|
secret: "",
|
||||||
@@ -257,7 +287,6 @@ const webhookForm = reactive({
|
|||||||
|
|
||||||
const emailForm = reactive({
|
const emailForm = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
min_severity: "warning",
|
|
||||||
smtp_host: "",
|
smtp_host: "",
|
||||||
smtp_port: 587,
|
smtp_port: 587,
|
||||||
smtp_user: "",
|
smtp_user: "",
|
||||||
@@ -298,23 +327,28 @@ async function load() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings; email: EmailSettings }>(
|
const data = await apiFetch<{
|
||||||
"/api/v1/settings/notifications",
|
policy: NotificationPolicy;
|
||||||
);
|
telegram: TelegramSettings;
|
||||||
|
webhook: WebhookSettings;
|
||||||
|
email: EmailSettings;
|
||||||
|
}>("/api/v1/settings/notifications");
|
||||||
|
policyLoaded.value = data.policy;
|
||||||
|
policyForm.min_severity = data.policy.min_severity;
|
||||||
|
policyForm.use_telegram = data.policy.use_telegram;
|
||||||
|
policyForm.use_webhook = data.policy.use_webhook;
|
||||||
|
policyForm.use_email = data.policy.use_email;
|
||||||
telegramLoaded.value = data.telegram;
|
telegramLoaded.value = data.telegram;
|
||||||
webhookLoaded.value = data.webhook;
|
webhookLoaded.value = data.webhook;
|
||||||
emailLoaded.value = data.email;
|
emailLoaded.value = data.email;
|
||||||
telegramForm.enabled = data.telegram.enabled;
|
telegramForm.enabled = data.telegram.enabled;
|
||||||
telegramForm.min_severity = data.telegram.min_severity;
|
|
||||||
telegramForm.bot_token = "";
|
telegramForm.bot_token = "";
|
||||||
telegramForm.chat_id = "";
|
telegramForm.chat_id = "";
|
||||||
webhookForm.enabled = data.webhook.enabled;
|
webhookForm.enabled = data.webhook.enabled;
|
||||||
webhookForm.min_severity = data.webhook.min_severity;
|
|
||||||
webhookForm.url = "";
|
webhookForm.url = "";
|
||||||
webhookForm.secret_header = data.webhook.secret_header ?? "";
|
webhookForm.secret_header = data.webhook.secret_header ?? "";
|
||||||
webhookForm.secret = "";
|
webhookForm.secret = "";
|
||||||
emailForm.enabled = data.email.enabled;
|
emailForm.enabled = data.email.enabled;
|
||||||
emailForm.min_severity = data.email.min_severity;
|
|
||||||
emailForm.smtp_host = "";
|
emailForm.smtp_host = "";
|
||||||
emailForm.smtp_port = data.email.smtp_port || 587;
|
emailForm.smtp_port = data.email.smtp_port || 587;
|
||||||
emailForm.smtp_user = data.email.smtp_user ?? "";
|
emailForm.smtp_user = data.email.smtp_user ?? "";
|
||||||
@@ -330,6 +364,28 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function savePolicy() {
|
||||||
|
savingPolicy.value = true;
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
try {
|
||||||
|
policyLoaded.value = await apiFetch<NotificationPolicy>("/api/v1/settings/notifications/policy", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
min_severity: policyForm.min_severity,
|
||||||
|
use_telegram: policyForm.use_telegram,
|
||||||
|
use_webhook: policyForm.use_webhook,
|
||||||
|
use_email: policyForm.use_email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
success.value = "Правило оповещений сохранено.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
|
||||||
|
} finally {
|
||||||
|
savingPolicy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTelegram() {
|
async function saveTelegram() {
|
||||||
savingTelegram.value = true;
|
savingTelegram.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -337,7 +393,6 @@ async function saveTelegram() {
|
|||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
enabled: telegramForm.enabled,
|
enabled: telegramForm.enabled,
|
||||||
min_severity: telegramForm.min_severity,
|
|
||||||
};
|
};
|
||||||
if (telegramForm.bot_token.trim()) body.bot_token = telegramForm.bot_token.trim();
|
if (telegramForm.bot_token.trim()) body.bot_token = telegramForm.bot_token.trim();
|
||||||
if (telegramForm.chat_id.trim()) body.chat_id = telegramForm.chat_id.trim();
|
if (telegramForm.chat_id.trim()) body.chat_id = telegramForm.chat_id.trim();
|
||||||
@@ -363,7 +418,6 @@ async function saveWebhook() {
|
|||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
enabled: webhookForm.enabled,
|
enabled: webhookForm.enabled,
|
||||||
min_severity: webhookForm.min_severity,
|
|
||||||
secret_header: webhookForm.secret_header.trim() || null,
|
secret_header: webhookForm.secret_header.trim() || null,
|
||||||
};
|
};
|
||||||
if (webhookForm.url.trim()) body.url = webhookForm.url.trim();
|
if (webhookForm.url.trim()) body.url = webhookForm.url.trim();
|
||||||
@@ -422,7 +476,6 @@ async function saveEmail() {
|
|||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
enabled: emailForm.enabled,
|
enabled: emailForm.enabled,
|
||||||
min_severity: emailForm.min_severity,
|
|
||||||
smtp_port: emailForm.smtp_port,
|
smtp_port: emailForm.smtp_port,
|
||||||
smtp_starttls: emailForm.smtp_starttls,
|
smtp_starttls: emailForm.smtp_starttls,
|
||||||
smtp_ssl: emailForm.smtp_ssl,
|
smtp_ssl: emailForm.smtp_ssl,
|
||||||
@@ -505,6 +558,30 @@ onMounted(load);
|
|||||||
max-width: 28rem;
|
max-width: 28rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-policy {
|
||||||
|
border-color: #3d4f66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hint-top {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-channels {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-channels legend {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #9aa4b2;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-inline {
|
.settings-inline {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user