Netpulse_SasS/server/internal/httpapi/alerts.go
byrsapty f6df538020 Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди
За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().

Тепер пишуться: правила алертів (створення/зміна/видалення й окремо
вимкнення-увімкнення), канали, драбини ескалації, правила
відповідності; додавання людини в кабінет, зміна ролі, вилучення,
правка профілю й скидання пароля.

Два рішення про зміст запису:
* вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці
  подробиць — питання «хто вимкнув правило, за яким приходив алерт»
  має відповідатись переліком, а не порівнянням;
* config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й
  адреса вебхука (доступ на запис у чужий чат) і заголовок
  Authorization. Замість нього прапорець secret_changed.

Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка
видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери
від шаблонів пишуться повз обробник, і пробне повідомлення в канал.

Прогнано проти справжньої бази: секрет каналу й пароль учасника в
подробиці не потрапляють.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:21 +03:00

837 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// Стеля ручного заглушення живе в шарі store: ту саму кнопку тепер
// натискають і з інтерфейсу, і з Telegram, а політика в них одна.
const maxMute = store.MaxMute
func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
f := store.AlertFilter{
MinSeverity: r.URL.Query().Get("min_severity"),
DeviceID: r.URL.Query().Get("device_id"),
Scope: p.Scope(),
}
if v := r.URL.Query().Get("state"); v != "" {
f.States = strings.Split(v, ",")
}
if v := r.URL.Query().Get("limit"); v != "" {
f.Limit, _ = strconv.Atoi(v)
}
alerts, err := s.store.ListAlerts(r.Context(), p.TenantID, f)
if err != nil {
s.log.Error("перелік алертів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if alerts == nil {
alerts = []store.Alert{}
}
counts, err := s.store.CountAlerts(r.Context(), p.TenantID, p.Scope())
if err != nil {
s.log.Error("лічильники алертів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts, "counts": counts})
}
func (s *Server) handleAckAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
var req struct {
Comment string `json:"comment"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
a, err := s.store.AckAlert(r.Context(), p.TenantID, r.PathValue("id"), p.UserID, req.Comment)
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("підтвердження алерту", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, a)
}
func (s *Server) handleCloseAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
err := s.store.CloseAlert(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("закриття алерту", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
var req struct {
DeviceID string `json:"device_id"`
Minutes int `json:"minutes"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if req.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібен device_id")
return
}
if req.Minutes <= 0 {
req.Minutes = 60
}
if !p.Scope().CanWrite(req.DeviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
return
}
until := time.Now().Add(time.Duration(req.Minutes) * time.Minute)
if max := time.Now().Add(maxMute); until.After(max) {
until = max
}
if err := s.store.MuteDevice(r.Context(), p.TenantID, req.DeviceID, p.UserID, req.Reason, until); err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор пристрою")
return
}
s.log.Error("заглушення пристрою", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"until": until})
}
// ---------------------------------------------------------------------
// Правила
// ---------------------------------------------------------------------
func (s *Server) handleListAlertRules(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
rules, err := s.store.ListRules(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік правил", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if rules == nil {
rules = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"rules": rules})
}
// validSeverities дублює ENUM alr.severity навмисно: помилка в назві
// має давати 400 зі зрозумілим текстом, а не 500 від невдалого
// приведення типу в глибині запиту.
var validSeverities = map[string]bool{
"info": true, "warning": true, "average": true, "high": true, "disaster": true,
}
func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Severity string `json:"severity"`
Selector json.RawMessage `json:"selector"`
Condition json.RawMessage `json:"condition"`
ForSeconds int `json:"for_seconds"`
DependsOnTopology *bool `json:"depends_on_topology"`
Enabled *bool `json:"enabled"`
// Куди слати. Порожньо — за загальними маршрутами тенанта.
ChannelIDs []string `json:"channel_ids"`
NotifySchedule json.RawMessage `json:"notify_schedule"`
NotifyOnResolve *bool `json:"notify_on_resolve"`
// Тільки для подієвих джерел.
AutoCloseSeconds *int `json:"auto_close_seconds"`
MinIntervalSeconds *int `json:"min_interval_seconds"`
// Драбина ескалації. Порожньо — без ескалації, і це типове
// значення: правило, яке нікого не будить понад перше
// сповіщення, поводиться рівно так, як поводилось до 0066.
EscalationPolicyID string `json:"escalation_policy_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва правила")
return
}
// Джерело, якого движок не вміє, тепер не зберігається мовчки.
//
// Саме мовчання й було помилкою: правило з джерелом `trap` лягало в
// базу, показувалось увімкненим і не спрацьовувало ніколи. Людина
// читала нуль алертів як «подій не було» — і була впевнена в цьому
// рівно доти, доки щось не згоріло.
if reason := store.UnsupportedSourceReason(req.Source); reason != "" {
writeError(w, http.StatusBadRequest, "bad_source", reason)
return
}
if req.Severity == "" {
req.Severity = "warning"
}
if !validSeverities[req.Severity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
return
}
if len(req.Condition) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна умова")
return
}
// Умову подієвого правила перевіряємо тут, а не при спрацюванні:
// зламаний регулярний вираз, помічений через пів року мовчання, —
// це та сама порожня обіцянка, тільки в іншому місці.
if err := store.ValidateRuleCondition(req.Source, req.Condition); err != nil {
writeError(w, http.StatusBadRequest, "bad_condition", err.Error())
return
}
if req.ForSeconds <= 0 {
req.ForSeconds = 60
}
// Подієвому алерту нема від чого «зникнути»: подія сталась і не
// перестане. Тому в нього є строк — і за замовчуванням це доба,
// тобто «встиг побачити на наступній зміні». Нуль лишається
// дозволеним і означає «закриє людина».
autoClose, minInterval := 0, 0
if store.IsEventSource(req.Source) {
autoClose, minInterval = 86400, 60
if req.AutoCloseSeconds != nil {
autoClose = *req.AutoCloseSeconds
}
if req.MinIntervalSeconds != nil {
minInterval = *req.MinIntervalSeconds
}
if autoClose < 0 || autoClose > 30*86400 {
writeError(w, http.StatusBadRequest, "bad_request",
"строк життя подієвого алерту — від 0 (тільки руками) до 30 діб")
return
}
if minInterval < 0 || minInterval > 3600 {
writeError(w, http.StatusBadRequest, "bad_request",
"проміжок між подіями — від 0 до 3600 секунд")
return
}
}
// Драбина має належати цьому ж кабінету.
//
// Зовнішній ключ перевіряє лише те, що така політика існує десь у
// базі, — не те, що вона ваша: перевірка FK іде повз RLS. Наслідок
// був би не витоком (чужу драбину все одно не прочитати), а гіршим:
// правило виглядало б налаштованим на ескалацію й не ескалювало б
// ніколи. Тобто рівно та мовчазна обіцянка, яку 0058 вже одного разу
// прибрала з тригерів.
if id := strings.TrimSpace(req.EscalationPolicyID); id != "" {
ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перевірка політики ескалації", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
found := false
for _, x := range ps {
if x.ID == id {
found = true
break
}
}
if !found {
writeError(w, http.StatusBadRequest, "bad_escalation",
"драбину ескалації не знайдено — оберіть її зі списку на сторінці «Ескалації»")
return
}
}
in := store.RuleInput{
Name: req.Name,
Description: req.Description,
Source: req.Source,
Severity: req.Severity,
Selector: jsonOr(req.Selector, "{}"),
Condition: string(req.Condition),
ForSeconds: req.ForSeconds,
DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology,
// `enabled` передається як є, разом з «поля не було».
//
// На створенні відсутність поля лишається згодою ввімкнути — це
// розумно й так було завжди (правило, заведене вимкненим, не
// робить нічого). На ОНОВЛЕННІ домислювати вже не можна: сервер
// ставив `true` кожному, хто поля не надіслав, тож правка опису
// вимкненого правила мовчки його вмикала — і людина дізнавалась
// про це зі сповіщення, а не з форми.
//
// Обрано саме «не було = не чіпати», а не «не було = 400»: PUT
// без `enabled` шле і власна форма, і будь-який чужий скрипт,
// написаний за цим API, тож вимога поля зламала б їх усі одним
// оновленням. Прапорець при цьому лишається керованим — його
// явно шлють і форма (тепер), і PATCH-перемикач.
Enabled: req.Enabled,
ChannelIDs: req.ChannelIDs,
NotifySchedule: string(req.NotifySchedule),
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
AutoCloseSeconds: autoClose,
MinIntervalSeconds: minInterval,
EscalationPolicyID: strings.TrimSpace(req.EscalationPolicyID),
}
if in.ChannelIDs == nil {
in.ChannelIDs = []string{}
}
if in.NotifySchedule == "null" {
in.NotifySchedule = ""
}
// Оновлення наявного правила приходить сюди ж: форма однакова, і
// розділяти її на дві гілки на клієнті означало б розійтися в
// перевірках рівно там, де вони найпотрібніші.
if id := r.PathValue("id"); id != "" {
ref, err := s.store.UpdateRule(r.Context(), p.TenantID, id, in)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
// Відмова від store — це відмова людині, а не збій сервера:
// саме тут виходить «правило шле в канал, якого немає в
// цьому кабінеті». Без цієї гілки перевірка спрацювала б, а
// назовні пішла б «внутрішня помилка» — тобто причини ніхто
// б не побачив.
if errors.Is(err, store.ErrInvalid) {
writeError(w, http.StatusBadRequest, "bad_channels", err.Error())
return
}
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
return
}
s.log.Error("оновлення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// Дію обирає перехід прапорця, а не те, що це PUT: збереження
// форми, у якому серед іншого зняли галочку «Увімкнено», — це
// вимкнення правила, і в журналі воно має так і називатись.
//
// Три виклики замість одного з обчисленим ключем — вимога
// перевірки словника (audit_actions_source_test.go): ключ, який
// приїхав змінною, нічим не звірити з переліком назв.
meta := alertRuleAuditMeta(in, ref.Enabled)
switch ruleSaveOutcome(ref) {
case ruleSavedAsDisable:
s.audit(r, p, store.AuditActionAlertRuleDisable, store.AuditObjectAlertRule, id, meta)
case ruleSavedAsEnable:
s.audit(r, p, store.AuditActionAlertRuleEnable, store.AuditObjectAlertRule, id, meta)
default:
s.audit(r, p, store.AuditActionAlertRuleUpdate, store.AuditObjectAlertRule, id, meta)
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
return
}
id, err := s.store.CreateRule(r.Context(), p.TenantID, p.UserID, in)
if err != nil {
if errors.Is(err, store.ErrInvalid) {
writeError(w, http.StatusBadRequest, "bad_channels", err.Error())
return
}
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
return
}
s.log.Error("створення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionAlertRuleCreate, store.AuditObjectAlertRule, id,
alertRuleAuditMeta(in, in.Enabled == nil || *in.Enabled))
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
var req struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Enabled == nil {
writeError(w, http.StatusBadRequest, "bad_request", "очікується поле enabled")
return
}
ref, err := s.store.SetRuleEnabled(r.Context(), p.TenantID, r.PathValue("id"), *req.Enabled)
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("зміна правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// Дію беремо з того, ЩО ПРОСИЛИ, а не з переходу (як у PUT вище):
// цей запит уміє рівно одне, і «вимкнути вже вимкнене» — теж
// вимкнення, тобто натиснута кнопка, а не порожня правка. Чи був
// перехід насправді, видно з was_enabled.
meta := map[string]any{
"name": ref.Name, "enabled": ref.Enabled, "was_enabled": ref.WasEnabled,
}
if *req.Enabled {
s.audit(r, p, store.AuditActionAlertRuleEnable, store.AuditObjectAlertRule, ref.ID, meta)
} else {
s.audit(r, p, store.AuditActionAlertRuleDisable, store.AuditObjectAlertRule, ref.ID, meta)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
ref, err := s.store.DeleteRule(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("видалення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// enabled — стан на момент видалення. Різниця відчутна: видалити
// правило, яке щойно будило зміну, і видалити те, що рік стояло
// вимкненим, — це різні події з однаковою назвою.
s.audit(r, p, store.AuditActionAlertRuleDelete, store.AuditObjectAlertRule, ref.ID,
map[string]any{"name": ref.Name, "enabled": ref.WasEnabled})
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Канали
// ---------------------------------------------------------------------
func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
// Секрети не розшифровуються: у переліку вони не потрібні, а
// найнадійніший спосіб не віддати токен назовні — не діставати його
// з бази взагалі.
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, nil)
if err != nil {
s.log.Error("перелік каналів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if channels == nil {
channels = []store.Channel{}
}
// Драбини, що спираються на канал, — частина відповіді на питання
// «що зламається, якщо його видалити». Те саме питання вже має
// відповідь у переліку драбин (rule_count), і тут вона потрібна з
// тієї ж причини.
//
// Невдача не валить перелік: без назв драбин сторінка лишається
// робочою, а без каналів — ні.
if refs, err := s.store.ChannelEscalationRefs(r.Context(), p.TenantID); err != nil {
s.log.Error("драбини на каналах", "err", err)
} else {
for i := range channels {
channels[i].Escalations = refs[channels[i].ID]
}
}
writeJSON(w, http.StatusOK, map[string]any{"channels": channels})
}
var validChannelKinds = map[string]bool{
"email": true, "telegram": true, "webhook": true, "slack": true,
"discord": true, "mattermost": true,
}
func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування")
return
}
var req struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
Template string `json:"template"`
MinSeverity string `json:"min_severity"`
Secret string `json:"secret"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва каналу")
return
}
if !validChannelKinds[req.Kind] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид каналу")
return
}
if req.MinSeverity == "" {
req.MinSeverity = "warning"
}
if !validSeverities[req.MinSeverity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
return
}
in := store.ChannelInput{
Kind: req.Kind,
Name: req.Name,
Config: jsonOr(req.Config, "{}"),
Template: req.Template,
MinSeverity: req.MinSeverity,
Secret: req.Secret,
Enabled: req.Enabled == nil || *req.Enabled,
}
// Оновлення наявного каналу приходить сюди ж — так само, як у
// правил вище: форма однакова, і розділяти її на дві гілки на
// клієнті означало б розійтися в перевірках.
//
// Цієї гілки НЕ БУЛО ЗОВСІМ. Маршрут PUT /channels/{id} стояв на
// цьому обробнику з першого дня, обробник читав тіло й завжди кликав
// CreateChannel, а написана й ретельно прокоментована UpdateChannel
// не мала жодного виклику в усьому дереві.
//
// Ззовні це виглядало так: правка каналу або заводила ДУБЛЬ, або
// якщо назву лишили — падала з «канал із такою назвою вже є». Тобто
// відредагувати канал було неможливо взагалі, і повідомлення про
// помилку вказувало не на ту причину.
if id := r.PathValue("id"); id != "" {
if err := s.store.UpdateChannel(r.Context(), p.TenantID, id, in, s.keyring); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
return
}
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "канал із такою назвою вже є")
return
}
if isBadUUID(err) {
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
return
}
s.log.Error("оновлення каналу", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionChannelUpdate, store.AuditObjectChannel, id,
channelAuditMeta(in))
writeJSON(w, http.StatusOK, map[string]any{"id": id})
return
}
id, err := s.store.CreateChannel(r.Context(), p.TenantID, in, s.keyring)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "канал із такою назвою вже є")
return
}
s.log.Error("створення каналу", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionChannelCreate, store.AuditObjectChannel, id,
channelAuditMeta(in))
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
ref, err := s.store.DeleteChannel(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("видалення каналу", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// Видалення каналу тихо переписує чужі об'єкти: посилання на нього
// зникають зі сходинок драбин, правил і маршрутів (див.
// DeleteChannel). Тому запис потрібен саме тут — у самих правилах
// сліду не лишиться.
s.audit(r, p, store.AuditActionChannelDelete, store.AuditObjectChannel, ref.ID,
map[string]any{"kind": ref.Kind, "name": ref.Name})
w.WriteHeader(http.StatusNoContent)
}
// handleTestChannel надсилає пробне повідомлення.
func (s *Server) handleTestChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
if s.notifier == nil || s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_notifier",
"доставку сповіщень не налаштовано в цьому процесі")
return
}
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, s.keyring)
if err != nil {
s.log.Error("читання каналів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
id := r.PathValue("id")
for _, c := range channels {
if c.ID != id {
continue
}
if err := s.notifier.TestChannel(r.Context(), p.TenantID, c); err != nil {
// Помилка каналу — це відповідь на питання «чи працює», а не
// збій сервера: 200 з описом кращий за 500 без нього.
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
}
// ---------------------------------------------------------------------
func jsonOr(raw json.RawMessage, def string) string {
if len(raw) == 0 {
return def
}
return string(raw)
}
func isBadUUID(err error) bool {
return err != nil && strings.Contains(err.Error(), "invalid input syntax for type uuid")
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505")
}
// ---------------------------------------------------------------------
// Політики ескалації
// ---------------------------------------------------------------------
func (s *Server) handleListEscalationPolicies(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
ps, err := s.store.ListEscalationPolicies(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік політик ескалації", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if ps == nil {
ps = []store.EscalationPolicy{}
}
writeJSON(w, http.StatusOK, map[string]any{"policies": ps})
}
// handleSaveEscalationPolicy приймає і створення, і зміну.
//
// Один обробник на обидва, як і в правил: форма показує драбину цілком,
// і розводити її на дві гілки означало б розійтися в перевірках рівно
// там, де вони найдорожчі — у тому, кого й коли будити вночі.
func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Steps []store.EscalationStep `json:"steps"`
RepeatAfterMin int `json:"repeat_after_min"`
MaxRepeats int `json:"max_repeats"`
// Відсутнє поле = false: та поведінка, що діяла досі. Оновлення
// не має тихо роззброїти нічні дзвінки в тих, у кого вони
// працюють, лише тому, що старий клієнт про поле не знає.
RespectQuietHours bool `json:"respect_quiet_hours"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва політики")
return
}
// Перевірка драбини живе в store, а не тут: ту саму відмову має
// давати і збереження з форми, і будь-який інший шлях запису.
if err := store.ValidateEscalationSteps(req.Steps); err != nil {
writeError(w, http.StatusBadRequest, "bad_steps", err.Error())
return
}
// Повтор без стелі — це нескінченний дзвінок, тому обидва числа
// перевіряються разом: «повторювати» без «скільки разів» не
// зберігається взагалі.
if req.RepeatAfterMin < 0 || req.RepeatAfterMin > 1440 {
writeError(w, http.StatusBadRequest, "bad_request",
"повтор драбини — від 1 до 1440 хвилин (0 — не повторювати)")
return
}
if req.MaxRepeats < 0 || req.MaxRepeats > store.MaxEscalationRepeats {
writeError(w, http.StatusBadRequest, "bad_request",
"повторів не більше "+strconv.Itoa(store.MaxEscalationRepeats))
return
}
if req.RepeatAfterMin > 0 && req.MaxRepeats == 0 {
writeError(w, http.StatusBadRequest, "bad_request",
"вказано проміжок повтору, але нуль повторів — драбина не повториться жодного разу")
return
}
pol := store.EscalationPolicy{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
Steps: req.Steps,
RepeatAfterMin: req.RepeatAfterMin,
MaxRepeats: req.MaxRepeats,
RespectQuietHours: req.RespectQuietHours,
}
// Порожній шлях = створення: збереження повертає id в обох
// випадках, і після нього відрізнити створення від правки нічим.
existing := r.PathValue("id") != ""
id, err := s.store.SaveEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"), pol)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
return
}
// Відмова від store — це відмова людині, а не збій сервера:
// саме тут повертається «сходинка N посилається на канал, якого
// немає в цьому кабінеті». Без цієї гілки вона вийшла б назовні
// як «внутрішня помилка», тобто перевірка спрацювала б, а
// причини ніхто б не побачив.
if errors.Is(err, store.ErrInvalid) {
writeError(w, http.StatusBadRequest, "bad_steps", err.Error())
return
}
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "політика з такою назвою вже є")
return
}
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("збереження політики ескалації", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if existing {
s.audit(r, p, store.AuditActionEscalationUpdate, store.AuditObjectEscalation, id,
escalationAuditMeta(pol))
} else {
s.audit(r, p, store.AuditActionEscalationCreate, store.AuditObjectEscalation, id,
escalationAuditMeta(pol))
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
}
func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
ref, err := s.store.DeleteEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("видалення політики ескалації", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionEscalationDelete, store.AuditObjectEscalation, ref.ID,
map[string]any{"name": ref.Name, "rule_count": ref.RuleCount})
w.WriteHeader(http.StatusNoContent)
}