From e0ba384705410e1bbe49a6a21c1da328e5c3489a Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 29 May 2026 15:57:35 +1000 Subject: [PATCH] feat: SAC Telegram settings view, notify_problem severity gate, exclusive docs Co-authored-by: Cursor --- backend/app/api/v1/router.py | 3 +- backend/app/api/v1/settings.py | 49 +++++++++++ backend/app/services/telegram_notify.py | 10 ++- backend/tests/test_settings_api.py | 21 +++++ backend/tests/test_telegram_notify.py | 73 +++++++++++++++++ deploy/env.native.example | 2 + docs/agent-integration.md | 26 +++++- docs/todo-2026-05-29.md | 10 +-- frontend/src/components/AppSidebar.vue | 1 + frontend/src/router.ts | 2 + frontend/src/views/SettingsView.vue | 103 ++++++++++++++++++++++++ 11 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 backend/app/api/v1/settings.py create mode 100644 backend/tests/test_settings_api.py create mode 100644 backend/tests/test_telegram_notify.py create mode 100644 frontend/src/views/SettingsView.vue diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index fdaf0a8..a796880 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, dashboards, events, health, hosts, problems, stream +from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream api_router = APIRouter() api_router.include_router(health.router) @@ -9,4 +9,5 @@ api_router.include_router(events.router) api_router.include_router(hosts.router) api_router.include_router(problems.router) api_router.include_router(dashboards.router) +api_router.include_router(settings.router) api_router.include_router(stream.router) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py new file mode 100644 index 0000000..45c1a5c --- /dev/null +++ b/backend/app/api/v1/settings.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from app.auth.jwt_auth import get_current_user +from app.config import get_settings + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None: + text = value.strip() + if not text: + return None + if len(text) <= visible_tail: + return "*" * len(text) + return f"{'*' * 8}…{text[-visible_tail:]}" + + +class TelegramSettingsResponse(BaseModel): + enabled: bool + configured: bool + chat_id_hint: str | None = None + bot_token_hint: str | None = None + min_severity: str + source: str = Field(default="env", description="env until UI persistence (notif-12)") + + +class NotificationSettingsResponse(BaseModel): + telegram: TelegramSettingsResponse + + +@router.get("/notifications", response_model=NotificationSettingsResponse) +def get_notification_settings( + _user: str = Depends(get_current_user), +) -> NotificationSettingsResponse: + settings = get_settings() + token = settings.telegram_bot_token.strip() + chat_id = settings.telegram_chat_id.strip() + configured = bool(token and chat_id) + return NotificationSettingsResponse( + telegram=TelegramSettingsResponse( + enabled=bool(settings.telegram_enabled and configured), + configured=configured, + chat_id_hint=_mask_secret(chat_id), + bot_token_hint=_mask_secret(token), + min_severity=settings.telegram_min_severity, + source="env", + ) + ) diff --git a/backend/app/services/telegram_notify.py b/backend/app/services/telegram_notify.py index 1a9e8e5..56acd60 100644 --- a/backend/app/services/telegram_notify.py +++ b/backend/app/services/telegram_notify.py @@ -37,10 +37,14 @@ def _send_text(message: str) -> None: logger.exception("telegram send failed") -def notify_event(event: Event) -> None: +def _should_notify_severity(severity: str) -> bool: settings = get_settings() min_level = _severity_value(settings.telegram_min_severity) - if _severity_value(event.severity) < min_level: + return _severity_value(severity) >= min_level + + +def notify_event(event: Event) -> None: + if not _should_notify_severity(event.severity): return host = event.host.hostname if event.host else "unknown" message = ( @@ -55,6 +59,8 @@ def notify_event(event: Event) -> None: def notify_problem(problem: Problem, event: Event | None = None) -> None: + if not _should_notify_severity(problem.severity): + return host = problem.host.hostname if problem.host else "unknown" related = "" if event is not None: diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py new file mode 100644 index 0000000..0d9fa81 --- /dev/null +++ b/backend/tests/test_settings_api.py @@ -0,0 +1,21 @@ +"""GET /api/v1/settings/notifications""" + +from app.services.telegram_notify import get_settings + + +def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatch): + monkeypatch.setenv("TELEGRAM_ENABLED", "true") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567890:ABCDEFghij") + monkeypatch.setenv("TELEGRAM_CHAT_ID", "2843230") + monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "warning") + get_settings.cache_clear() + + response = client.get("/api/v1/settings/notifications", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["telegram"]["enabled"] is True + assert body["telegram"]["configured"] is True + assert body["telegram"]["min_severity"] == "warning" + assert body["telegram"]["source"] == "env" + assert body["telegram"]["bot_token_hint"].endswith("ghij") + assert "1234567890" not in body["telegram"]["bot_token_hint"] diff --git a/backend/tests/test_telegram_notify.py b/backend/tests/test_telegram_notify.py new file mode 100644 index 0000000..1bf4ff5 --- /dev/null +++ b/backend/tests/test_telegram_notify.py @@ -0,0 +1,73 @@ +"""Tests for SAC Telegram notification helpers.""" + +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest + +from app.models import Event, Host, Problem +from app.services import telegram_notify + + +def test_notify_event_respects_min_severity(monkeypatch): + sent: list[str] = [] + + def fake_send(message: str) -> None: + sent.append(message) + + monkeypatch.setenv("TELEGRAM_ENABLED", "true") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token") + monkeypatch.setenv("TELEGRAM_CHAT_ID", "123") + monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "warning") + telegram_notify.get_settings.cache_clear() + + event = Event( + event_id="00000000-0000-4000-8000-000000000001", + host_id=1, + occurred_at=datetime(2026, 5, 28, 12, 0, tzinfo=timezone.utc), + category="auth", + type="rdp.login.success", + severity="info", + title="ok", + summary="info event", + payload={}, + ) + + with patch.object(telegram_notify, "_send_text", side_effect=fake_send): + telegram_notify.notify_event(event) + assert sent == [] + + event.severity = "warning" + with patch.object(telegram_notify, "_send_text", side_effect=fake_send): + telegram_notify.notify_event(event) + assert len(sent) == 1 + + +def test_notify_problem_respects_min_severity(monkeypatch): + sent: list[str] = [] + + def fake_send(message: str) -> None: + sent.append(message) + + monkeypatch.setenv("TELEGRAM_ENABLED", "true") + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token") + monkeypatch.setenv("TELEGRAM_CHAT_ID", "123") + monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "high") + telegram_notify.get_settings.cache_clear() + + problem = Problem( + title="brute", + summary="burst", + severity="warning", + status="open", + fingerprint="fp1", + ) + + with patch.object(telegram_notify, "_send_text", side_effect=fake_send): + telegram_notify.notify_problem(problem) + assert sent == [] + + problem.severity = "high" + with patch.object(telegram_notify, "_send_text", side_effect=fake_send): + telegram_notify.notify_problem(problem) + assert len(sent) == 1 diff --git a/deploy/env.native.example b/deploy/env.native.example index 03bbf51..17cf7b8 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -18,9 +18,11 @@ SAC_ADMIN_USERNAME=admin SAC_ADMIN_PASSWORD= # Telegram notifications from SAC (phase 1C.4) +# При UseSAC=exclusive на агентах — основной канал для оператора; см. docs/agent-integration.md §1.3.1 TELEGRAM_ENABLED=false TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= +# exclusive: warning (failed login, problems); prod noise-only: high TELEGRAM_MIN_SEVERITY=high # Статус хоста по agent.heartbeat (минуты; ssh-monitor шлёт ~раз в 12 ч) diff --git a/docs/agent-integration.md b/docs/agent-integration.md index f2fda55..3e53e56 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -46,6 +46,30 @@ $SacSpoolDir = "D:\Soft\Logs\sac-spool" - `UseSAC≠off` — обязательны `SAC_URL`, `SAC_API_KEY`; HTTP `GET {base}/health` OK. - `--check-sac` / `Test-SacConnection` — отправка `agent.test`, ожидание **HTTP 201** (повтор с тем же `event_id` — **409**, тоже успех для spool). +### 1.3.1. Telegram и email при `UseSAC=exclusive` + +При **`exclusive`** агент **не** шлёт Telegram/email — оператору нужны оповещения **из SAC** (`backend/app/services/telegram_notify.py`). + +На сервере SAC в **`/opt/security-alert-center/config/sac-api.env`** (см. `deploy/env.native.example`): + +```env +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) | +|------------------|-----------|----------------------------------| +| `info` (успешный RDP/SSH login) | нет | нет | +| `warning` (`*.login.failed`, sudo) | **да** | нет | +| `high` / `critical` (ban, problem) | **да** | **да** | + +Problems (`notify_problem`) учитывают тот же порог `TELEGRAM_MIN_SEVERITY`. + +UI **Настройки** (`/settings`) — просмотр маскированной конфигурации; запись в env — до задачи `notif-12` (БД) или правка `sac-api.env` + restart `sac-api`. + ### 1.4. Версии и доставка обновлений При **любом** изменении агента, влияющем на SAC или поведение на хосте, поднимайте версию и пушьте в **git.kalinamall.ru**: @@ -53,7 +77,7 @@ $SacSpoolDir = "D:\Soft\Logs\sac-spool" | Агент | Маркер версии | Как хост узнаёт о новой версии | |-------|---------------|--------------------------------| | **ssh-monitor** | `SSH_MONITOR_VERSION` в `ssh-monitor`; `# SAC client release:` в `sac-client.sh` | `update_ssh_monitor.sh`: `git pull` в клоне → сравнение sha256 `ssh-monitor` и `sac-client.sh` | -| **RDP-login-monitor** | `$ScriptVersion` в `Login_Monitor.ps1` и **та же** строка в `version.txt` на шаре NETLOGON | `Deploy-LoginMonitor.ps1`: `version.txt` на шаре > `deployed_version.txt` → копирует `Login_Monitor.ps1` и `Sac-Client.ps1` | +| **RDP-login-monitor** | `$ScriptVersion` в `Login_Monitor.ps1` и **та же** строка в `version.txt` на шаре NETLOGON | `Deploy-LoginMonitor.ps1`: сверка `version.txt` и SHA256 пакета (`Login_Monitor.ps1`, `Sac-Client.ps1`); при отсутствии SAC в settings — **`UseSAC=dual`** из example; подсказка `# $ServerDisplayName` | **Кириллица в SAC (RDP):** до **1.2.8-SAC** `Invoke-WebRequest` мог слать JSON не в UTF-8 — в UI «Отчёты» summary вида `RDP 24?: ??????` вместо `RDP 24ч: сессий …`. Обновите `Sac-Client.ps1` на всех хостах; старые события в БД не пересчитываются, исправятся только новые ingest после деплоя. diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md index 2dc8159..348d00e 100644 --- a/docs/todo-2026-05-29.md +++ b/docs/todo-2026-05-29.md @@ -26,18 +26,18 @@ | ID | Задача | Критерий | |----|--------|----------| -| `notif-01` | Зафиксировать в `agent-integration.md` / runbook: при **exclusive** канал оповещений = SAC; рекомендуемый `TELEGRAM_MIN_SEVERITY=warning` (или `high` только для prod-шума) | Документ + пример `sac-api.env` | +| `notif-01` | Зафиксировать в `agent-integration.md` / runbook: при **exclusive** канал оповещений = SAC; рекомендуемый `TELEGRAM_MIN_SEVERITY=warning` | ✅ docs + `env.native.example` | | `notif-02` | Проверить на staging: ingest `rdp.login.failed` (warning) → TG; `rdp.login.success` (info) → нет TG при `min=warning` | Чеклист в E2E | -| `notif-03` | `notify_problem`: учитывать `telegram_min_severity`; опционально отдельный порог для problems | Unit-тест | +| `notif-03` | `notify_problem`: учитывать `telegram_min_severity` | ✅ unit-тест | ### P1 — UI «Настройки» (каналы) | ID | Задача | Критерий | |----|--------|----------| -| `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | Страница открывается | -| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity (`info`/`warning`/`high`/`critical`) | GET/PUT API + форма | +| `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | ✅ страница + GET API | +| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity | ✅ GET (read-only, маска); PUT — `notif-12` | | `notif-12` | Хранение: **вариант A** — запись в БД `notification_channels` + fallback на env; **вариант B** — только env, UI read-only | Решение в комментарии к PR | -| `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`); запись только при изменении | Без утечки в логах/UI | +| `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`) | ✅ GET settings | | `notif-14` | Кнопка «Проверить Telegram» → тестовое сообщение | 200 / понятная ошибка | ### P2 — Email и webhook (после Telegram) diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 7b1e60c..94fe1f2 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -44,6 +44,7 @@ const navItems = [ { to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const }, { to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const }, { to: "/hosts", label: "Хосты", icon: "⬢", match: "exact" as const }, + { to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const }, ]; function isActive(item: (typeof navItems)[number]) { diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 9794873..73956fe 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -8,6 +8,7 @@ import ProblemsView from "./views/ProblemsView.vue"; import ProblemDetailView from "./views/ProblemDetailView.vue"; import DashboardView from "./views/DashboardView.vue"; import ReportsView from "./views/ReportsView.vue"; +import SettingsView from "./views/SettingsView.vue"; const router = createRouter({ history: createWebHistory(), @@ -21,6 +22,7 @@ const router = createRouter({ { path: "/hosts", component: HostsView }, { path: "/problems", component: ProblemsView }, { path: "/problems/:id", component: ProblemDetailView, props: true }, + { path: "/settings", component: SettingsView }, ], }); diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..684d3f0 --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,103 @@ + + + + +