Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди
За цілий день активної роботи в журналі не з'явилось нічого: цих доменів у ньому просто не було. Проєкт це визнавав сам, у AuditBlindSpots(). Тепер пишуться: правила алертів (створення/зміна/видалення й окремо вимкнення-увімкнення), канали, драбини ескалації, правила відповідності; додавання людини в кабінет, зміна ролі, вилучення, правка профілю й скидання пароля. Два рішення про зміст запису: * вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці подробиць — питання «хто вимкнув правило, за яким приходив алерт» має відповідатись переліком, а не порівнянням; * config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й адреса вебхука (доступ на запис у чужий чат) і заголовок Authorization. Замість нього прапорець secret_changed. Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери від шаблонів пишуться повз обробник, і пробне повідомлення в канал. Прогнано проти справжньої бази: секрет каналу й пароль учасника в подробиці не потрапляють. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6de46550ea
commit
f6df538020
16 changed files with 1176 additions and 60 deletions
|
|
@ -338,7 +338,8 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
||||||
// розділяти її на дві гілки на клієнті означало б розійтися в
|
// розділяти її на дві гілки на клієнті означало б розійтися в
|
||||||
// перевірках рівно там, де вони найпотрібніші.
|
// перевірках рівно там, де вони найпотрібніші.
|
||||||
if id := r.PathValue("id"); id != "" {
|
if id := r.PathValue("id"); id != "" {
|
||||||
if err := s.store.UpdateRule(r.Context(), p.TenantID, id, in); err != nil {
|
ref, err := s.store.UpdateRule(r.Context(), p.TenantID, id, in)
|
||||||
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
||||||
return
|
return
|
||||||
|
|
@ -360,6 +361,22 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -378,6 +395,8 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
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})
|
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,7 +412,7 @@ func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.store.SetRuleEnabled(r.Context(), p.TenantID, r.PathValue("id"), *req.Enabled)
|
ref, err := s.store.SetRuleEnabled(r.Context(), p.TenantID, r.PathValue("id"), *req.Enabled)
|
||||||
if errors.Is(err, store.ErrAlertNotFound) {
|
if errors.Is(err, store.ErrAlertNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
||||||
return
|
return
|
||||||
|
|
@ -407,6 +426,18 @@ func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,7 +445,7 @@ func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p
|
||||||
if !requirePerm(w, p, "alerts:write") {
|
if !requirePerm(w, p, "alerts:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := s.store.DeleteRule(r.Context(), p.TenantID, r.PathValue("id"))
|
ref, err := s.store.DeleteRule(r.Context(), p.TenantID, r.PathValue("id"))
|
||||||
if errors.Is(err, store.ErrAlertNotFound) {
|
if errors.Is(err, store.ErrAlertNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
||||||
return
|
return
|
||||||
|
|
@ -428,6 +459,11 @@ func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -553,6 +589,8 @@ func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.audit(r, p, store.AuditActionChannelUpdate, store.AuditObjectChannel, id,
|
||||||
|
channelAuditMeta(in))
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -567,6 +605,8 @@ func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.audit(r, p, store.AuditActionChannelCreate, store.AuditObjectChannel, id,
|
||||||
|
channelAuditMeta(in))
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,7 +614,7 @@ func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *
|
||||||
if !requirePerm(w, p, "alerts:write") {
|
if !requirePerm(w, p, "alerts:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := s.store.DeleteChannel(r.Context(), p.TenantID, r.PathValue("id"))
|
ref, err := s.store.DeleteChannel(r.Context(), p.TenantID, r.PathValue("id"))
|
||||||
if errors.Is(err, store.ErrAlertNotFound) {
|
if errors.Is(err, store.ErrAlertNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
|
||||||
return
|
return
|
||||||
|
|
@ -588,6 +628,12 @@ func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -717,16 +763,20 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := s.store.SaveEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"),
|
pol := store.EscalationPolicy{
|
||||||
store.EscalationPolicy{
|
Name: strings.TrimSpace(req.Name),
|
||||||
Name: strings.TrimSpace(req.Name),
|
Description: req.Description,
|
||||||
Description: req.Description,
|
Steps: req.Steps,
|
||||||
Steps: req.Steps,
|
RepeatAfterMin: req.RepeatAfterMin,
|
||||||
RepeatAfterMin: req.RepeatAfterMin,
|
MaxRepeats: req.MaxRepeats,
|
||||||
MaxRepeats: req.MaxRepeats,
|
|
||||||
|
|
||||||
RespectQuietHours: req.RespectQuietHours,
|
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 err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
||||||
|
|
@ -753,6 +803,13 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -760,7 +817,7 @@ func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Req
|
||||||
if !requirePerm(w, p, "alerts:write") {
|
if !requirePerm(w, p, "alerts:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := s.store.DeleteEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"))
|
ref, err := s.store.DeleteEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"))
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
|
||||||
return
|
return
|
||||||
|
|
@ -774,5 +831,7 @@ func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Req
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
270
server/internal/httpapi/audit_meta.go
Normal file
270
server/internal/httpapi/audit_meta.go
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Що саме лягає в подробиці журналу для алертної частини й складу
|
||||||
|
// команди.
|
||||||
|
//
|
||||||
|
// ЧОМУ ЦЕ ОКРЕМИЙ ФАЙЛ І ЧОМУ ЦЕ ЧИСТІ ФУНКЦІЇ
|
||||||
|
//
|
||||||
|
// Обидві зони, які тут закриваються, мають по одному правилу, яке не
|
||||||
|
// можна порушити жодного разу, і обидва — про ВМІСТ запису, а не про
|
||||||
|
// сам факт запису:
|
||||||
|
//
|
||||||
|
// 1. Канал сповіщень несе токен бота, а його config — адресу вебхука
|
||||||
|
// й довільні заголовки. Адреса вебхука Slack чи Discord і є доступ:
|
||||||
|
// хто її знає, той пише в чужий чат. Запис «змінено канал» не має
|
||||||
|
// права нести нічого з цього.
|
||||||
|
// 2. Пароль учасника не потрапляє в журнал ні в якому вигляді — ні
|
||||||
|
// новий, ні старий, ні його довжина.
|
||||||
|
//
|
||||||
|
// Перевірити це на живих обробниках можна лише з базою, тобто в
|
||||||
|
// прогоні, який без Docker мовчки пропускається (див. scripts/dbtest.sh
|
||||||
|
// про те, чого це коштувало проєкту раніше). Тому збирання подробиць
|
||||||
|
// винесено сюди чистими функціями: тест бере вхід із секретом у кожному
|
||||||
|
// полі, серіалізує результат і шукає в ньому секрет — без бази, без
|
||||||
|
// HTTP і без пропусків.
|
||||||
|
//
|
||||||
|
// Другий наслідок того ж рішення: правило «що можна класти» живе в
|
||||||
|
// одному місці. Наступний, хто вирішить додати в подробиці ще одне
|
||||||
|
// поле каналу, зробить це тут — поруч із тестом, який на секрет упаде.
|
||||||
|
|
||||||
|
// audit пише подію й не перетворює збій журналу на помилку запиту.
|
||||||
|
//
|
||||||
|
// Порядок і причина ті самі, що в auditRole (roles.go): дія вже
|
||||||
|
// сталась, і сховати її від людини було б гірше, ніж лишити слід самих
|
||||||
|
// лише логів сервера.
|
||||||
|
func (s *Server) audit(r *http.Request, p *Principal, action, objectType, objectID string,
|
||||||
|
meta map[string]any) {
|
||||||
|
|
||||||
|
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||||
|
ActorUserID: p.UserID,
|
||||||
|
ActorTokenID: p.TokenID,
|
||||||
|
ActorIP: clientIP(r),
|
||||||
|
Action: action,
|
||||||
|
ObjectType: objectType,
|
||||||
|
ObjectID: objectID,
|
||||||
|
Meta: meta,
|
||||||
|
}); err != nil {
|
||||||
|
s.log.Error("аудит", "дія", action, "об'єкт", objectID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonMeta кладе в подробиці готовий JSON як JSON, а не як рядок у
|
||||||
|
// лапках з екранованими лапками всередині.
|
||||||
|
//
|
||||||
|
// Перевірка на валідність не зайва: подробиці серіалізуються цілком, і
|
||||||
|
// один невалідний шматок завалив би ВЕСЬ запис — тобто події просто не
|
||||||
|
// стало б. Краще рядок, який видно, ніж запис, якого немає.
|
||||||
|
func jsonMeta(raw string) any {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !json.Valid([]byte(raw)) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return json.RawMessage(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Правила алертів
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// alertRuleAuditMeta — подробиці створення чи зміни правила.
|
||||||
|
//
|
||||||
|
// Умова й селектор їдуть повністю, і це головне в записі. Правило, у
|
||||||
|
// якого поріг підняли з 80 % до 99 % або селектор звузили до одного
|
||||||
|
// вузла, лишається в списку ввімкненим і виглядає робочим — саме такі
|
||||||
|
// правки й шукають у журналі. Знімка «як було» тут немає: UpdateRule
|
||||||
|
// замінює правило цілком, і попередній стан лежить у попередньому
|
||||||
|
// записі журналу за тим самим object_id.
|
||||||
|
func alertRuleAuditMeta(in store.RuleInput, enabled bool) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"name": in.Name,
|
||||||
|
"source": in.Source,
|
||||||
|
"severity": in.Severity,
|
||||||
|
"enabled": enabled,
|
||||||
|
"for_seconds": in.ForSeconds,
|
||||||
|
"selector": jsonMeta(in.Selector),
|
||||||
|
"condition": jsonMeta(in.Condition),
|
||||||
|
// Ідентифікатори каналів — не секрет: це посилання на канал, а
|
||||||
|
// не спосіб у нього написати. Зате вони відповідають на «куди
|
||||||
|
// це правило шле» через рік, коли самого каналу вже немає.
|
||||||
|
"channel_ids": in.ChannelIDs,
|
||||||
|
"escalation_policy_id": in.EscalationPolicyID,
|
||||||
|
"notify_on_resolve": in.NotifyOnResolve,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleSaveKind — чим насправді стало збереження форми правила.
|
||||||
|
//
|
||||||
|
// Рішення винесене з обробника окремо не заради краси: сам вибір ключа
|
||||||
|
// в обробнику записаний трьома гілками з константами (інакше перевірка
|
||||||
|
// словника не звірить ключ із переліком назв), а ось ЩО саме вважати
|
||||||
|
// вимкненням — це правило, і воно має бути перевірене без бази й без
|
||||||
|
// HTTP. Питання «чи вважається вимкненням збереження вже вимкненого
|
||||||
|
// правила» має рівно одну правильну відповідь, і вгадувати її щоразу
|
||||||
|
// заново не варто.
|
||||||
|
type ruleSaveKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ruleSavedAsUpdate ruleSaveKind = iota
|
||||||
|
ruleSavedAsDisable
|
||||||
|
ruleSavedAsEnable
|
||||||
|
)
|
||||||
|
|
||||||
|
// ruleSaveOutcome дивиться на ПЕРЕХІД, а не на кінцевий стан.
|
||||||
|
//
|
||||||
|
// Збереження вимкненого правила, яке лишилось вимкненим, — звичайна
|
||||||
|
// правка: нічого не замовкло, бо воно вже мовчало. А от та сама форма,
|
||||||
|
// у якій зняли галочку, гасить усі активні алерти правила й прибирає
|
||||||
|
// його з вибірки движка — і в журналі це має називатись вимкненням, хай
|
||||||
|
// навіть людина прийшла правити опис.
|
||||||
|
func ruleSaveOutcome(ref store.RuleRef) ruleSaveKind {
|
||||||
|
switch {
|
||||||
|
case ref.WasEnabled && !ref.Enabled:
|
||||||
|
return ruleSavedAsDisable
|
||||||
|
case !ref.WasEnabled && ref.Enabled:
|
||||||
|
return ruleSavedAsEnable
|
||||||
|
default:
|
||||||
|
return ruleSavedAsUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Канали сповіщень
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// channelAuditMeta — подробиці створення чи зміни каналу.
|
||||||
|
//
|
||||||
|
// СЕКРЕТУ Й CONFIG ТУТ НЕМАЄ І НЕ БУДЕ. Токен бота очевидний, а config
|
||||||
|
// не менш небезпечний: у webhook там лежить `url` (адреса вебхука Slack
|
||||||
|
// чи Discord — це доступ на запис у чужий чат) і `headers`, куди люди
|
||||||
|
// кладуть Authorization. Журнал аудиту читає ширше коло, ніж сторінка
|
||||||
|
// каналів, і копія токена в ньому означала б, що право «дивитись, хто
|
||||||
|
// що робив» тихо стало правом «писати від імені компанії».
|
||||||
|
//
|
||||||
|
// Замість секрету — secret_changed: «у цьому запиті приїхав новий
|
||||||
|
// токен». Саме «змінено», а не «задано»: порожній секрет на правці
|
||||||
|
// каналу означає «лишити наявний» (див. UpdateChannel), тож «задано:
|
||||||
|
// ні» читалося б як «токен стерли» — тобто журнал відповідав би
|
||||||
|
// НЕПРАВДОЮ на те саме питання, заради якого це поле й з'явилось.
|
||||||
|
func channelAuditMeta(in store.ChannelInput) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"kind": in.Kind,
|
||||||
|
"name": in.Name,
|
||||||
|
"min_severity": in.MinSeverity,
|
||||||
|
"enabled": in.Enabled,
|
||||||
|
"secret_changed": in.Secret != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Драбини ескалації
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// escalationAuditMeta — подробиці збереження драбини.
|
||||||
|
//
|
||||||
|
// Сходинки їдуть повністю: драбина — це перелік того, кого і через
|
||||||
|
// скільки хвилин будять уночі, і «змінено драбину» без самих сходинок
|
||||||
|
// не відповідає ні на що. Тиха година окремим полем із тієї ж причини,
|
||||||
|
// що й вимкнення правила: respect_quiet_hours = true означає, що вночі
|
||||||
|
// драбина мовчатиме, тобто резервний механізм не спрацює саме тоді,
|
||||||
|
// коли він і потрібен.
|
||||||
|
func escalationAuditMeta(pol store.EscalationPolicy) map[string]any {
|
||||||
|
steps := pol.Steps
|
||||||
|
if steps == nil {
|
||||||
|
steps = []store.EscalationStep{}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"name": pol.Name,
|
||||||
|
"steps": steps,
|
||||||
|
"repeat_after_min": pol.RepeatAfterMin,
|
||||||
|
"max_repeats": pol.MaxRepeats,
|
||||||
|
"respect_quiet_hours": pol.RespectQuietHours,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Правила відповідності
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// complianceRuleAuditMeta — подробиці збереження правила відповідності.
|
||||||
|
//
|
||||||
|
// Зразок (pattern) — предмет перевірки, і без нього запис не відповідає
|
||||||
|
// на головне питання: правило з тією ж назвою може перевіряти вже не те
|
||||||
|
// саме. Вимкнення лягає полем enabled, а не окремою дією, і це
|
||||||
|
// свідома різниця з алертним правилом: вимкнене правило відповідності
|
||||||
|
// лишається видимим у списку й окремим розділом у звіті, тобто факт
|
||||||
|
// «вимогу знято» видно й без журналу.
|
||||||
|
func complianceRuleAuditMeta(rule store.ComplianceRule) map[string]any {
|
||||||
|
meta := map[string]any{
|
||||||
|
"name": rule.Name,
|
||||||
|
"kind": rule.Kind,
|
||||||
|
"severity": rule.Severity,
|
||||||
|
"config_type": rule.ConfigType,
|
||||||
|
"enabled": rule.Enabled,
|
||||||
|
"pattern": rule.Pattern,
|
||||||
|
}
|
||||||
|
if rule.BuiltinKey != "" {
|
||||||
|
meta["builtin_key"] = rule.BuiltinKey
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Склад команди
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// memberRoleAuditMeta — подробиці зміни ролі учасника.
|
||||||
|
//
|
||||||
|
// Обидві ролі, «з» і «на», з ключем і назвою кожної: це і є відповідь
|
||||||
|
// на «звідки в цієї людини такий доступ». Ключ — те, за чим шукають;
|
||||||
|
// назва — те, що людина впізнає й чого через рік може вже не бути.
|
||||||
|
func memberRoleAuditMeta(before, after store.MemberRef) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"username": after.Username,
|
||||||
|
"before": map[string]any{
|
||||||
|
"role_id": before.RoleID, "role_key": before.RoleKey, "role_name": before.RoleName,
|
||||||
|
},
|
||||||
|
"after": map[string]any{
|
||||||
|
"role_id": after.RoleID, "role_key": after.RoleKey, "role_name": after.RoleName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberProfileAuditMeta — подробиці правки профілю учасника
|
||||||
|
// адміністратором.
|
||||||
|
//
|
||||||
|
// У fields — перелік того, що змінили: порожній рядок у
|
||||||
|
// UserProfileInput означає «не чіпати», тож без цього переліку запис не
|
||||||
|
// відрізняв би перейменування від скидання пароля. Самого пароля тут
|
||||||
|
// немає ні в якому вигляді: у переліку стоїть слово "password", тобто
|
||||||
|
// «пароль міняли», і нічого більше — ні нового, ні старого, ні його
|
||||||
|
// довжини.
|
||||||
|
//
|
||||||
|
// Якою саме дією назветься запит, вирішує обробник (users.go): пароль
|
||||||
|
// перебиває решту полів і має власну назву дії з тієї ж причини, що
|
||||||
|
// вимкнення перебиває зміну правила — найдорожчий факт має бути видно
|
||||||
|
// з назви, а не з порівняння переліків.
|
||||||
|
func memberProfileAuditMeta(in store.UserProfileInput, username string) map[string]any {
|
||||||
|
var fields []string
|
||||||
|
if in.Username != "" {
|
||||||
|
fields = append(fields, "username")
|
||||||
|
}
|
||||||
|
if in.Email != "" {
|
||||||
|
fields = append(fields, "email")
|
||||||
|
}
|
||||||
|
if in.FullName != "" {
|
||||||
|
fields = append(fields, "full_name")
|
||||||
|
}
|
||||||
|
if in.Password != "" {
|
||||||
|
fields = append(fields, "password")
|
||||||
|
}
|
||||||
|
return map[string]any{"username": username, "fields": fields}
|
||||||
|
}
|
||||||
273
server/internal/httpapi/audit_meta_test.go
Normal file
273
server/internal/httpapi/audit_meta_test.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Що саме потрапляє в подробиці журналу — БЕЗ бази навмисно.
|
||||||
|
//
|
||||||
|
// Решта тестів цього пакета йде проти справжнього Postgres і сама себе
|
||||||
|
// пропускає без NETPULSE_TEST_DSN. Для головного правила цієї роботи —
|
||||||
|
// «секрет каналу не потрапляє в журнал» — така перевірка не годиться:
|
||||||
|
// пропущений тест у підсумку виглядає точно як пройдений (див. шапку
|
||||||
|
// scripts/dbtest.sh), а ціна помилки тут не «сторінка показала не те», а
|
||||||
|
// копія токена бота в таблиці, яку читає ширше коло людей, ніж сторінку
|
||||||
|
// каналів. Тому подробиці збираються чистими функціями, і перевіряються
|
||||||
|
// вони тут — на кожній машині й на кожному прогоні.
|
||||||
|
|
||||||
|
// marker — рядки, яких у журналі бути НЕ МОЖЕ. Кожен упізнаваний
|
||||||
|
// настільки, що випадковий збіг виключений.
|
||||||
|
const (
|
||||||
|
botToken = "1234567:AAH-СЕКРЕТНИЙ-ТОКЕН-БОТА-не-в-журнал"
|
||||||
|
hookURL = "https://hooks.slack.com/services/T00/B11/СЕКРЕТНИЙ-ШЛЯХ"
|
||||||
|
authHeader = "Bearer СЕКРЕТНИЙ-ЗАГОЛОВОК-АВТОРИЗАЦІЇ"
|
||||||
|
userPass = "СЕКРЕТНИЙ-ПАРОЛЬ-УЧАСНИКА-42"
|
||||||
|
)
|
||||||
|
|
||||||
|
// metaJSON серіалізує подробиці рівно так, як це зробить WriteAudit.
|
||||||
|
//
|
||||||
|
// Саме серіалізований вигляд і треба обшукувати: секрет може приїхати
|
||||||
|
// не полем, а всередині вкладеної структури або json.RawMessage, і
|
||||||
|
// перевірка окремих ключів мапи його б не побачила.
|
||||||
|
func metaJSON(t *testing.T, meta map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("подробиці не серіалізуються — запис у журнал не відбувся б: %v", err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Головний тест роботи.
|
||||||
|
func TestChannelAuditMetaNeverCarriesSecret(t *testing.T) {
|
||||||
|
in := store.ChannelInput{
|
||||||
|
Kind: "telegram",
|
||||||
|
Name: "Чергова зміна",
|
||||||
|
// config несе адресу вебхука й заголовки — це такий самий
|
||||||
|
// доступ, як токен: хто знає адресу, той пише в чужий чат.
|
||||||
|
Config: `{"url":"` + hookURL + `","headers":{"Authorization":"` + authHeader + `"},` +
|
||||||
|
`"chat_id":-1001234567890}`,
|
||||||
|
Template: "{{.Title}}",
|
||||||
|
MinSeverity: "high",
|
||||||
|
Secret: botToken,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := metaJSON(t, channelAuditMeta(in))
|
||||||
|
|
||||||
|
for _, secret := range []string{botToken, hookURL, authHeader} {
|
||||||
|
if strings.Contains(got, secret) {
|
||||||
|
t.Fatalf("секрет потрапив у подробиці журналу: %q\nподробиці: %s", secret, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Окремо: жодного шматка config, навіть безневинного на вигляд.
|
||||||
|
// Правило тут не «сховати відомі секрети», а «не пускати config
|
||||||
|
// узагалі» — інакше наступний вид каналу принесе новий секрет під
|
||||||
|
// новим ім'ям поля, і його ніхто не помітить.
|
||||||
|
if strings.Contains(got, "hooks.slack.com") || strings.Contains(got, "chat_id") {
|
||||||
|
t.Fatalf("у подробиці просочився config каналу: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(got, "Чергова зміна") || !strings.Contains(got, "telegram") {
|
||||||
|
t.Fatalf("запис не відповідає на «що саме за канал»: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"secret_changed":true`) {
|
||||||
|
t.Fatalf("факт заміни токена загубився: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правка, у якій секрет не чіпали, має відрізнятись від правки, у якій
|
||||||
|
// приїхав новий токен: порожній секрет означає «лишити наявний», і
|
||||||
|
// журнал не має вдавати, ніби токен стерли.
|
||||||
|
func TestChannelAuditMetaMarksUntouchedSecret(t *testing.T) {
|
||||||
|
got := metaJSON(t, channelAuditMeta(store.ChannelInput{
|
||||||
|
Kind: "webhook", Name: "Черговий вебхук", MinSeverity: "warning",
|
||||||
|
}))
|
||||||
|
if !strings.Contains(got, `"secret_changed":false`) {
|
||||||
|
t.Fatalf("незмінений секрет позначено як змінений: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRuleAuditMetaKeepsSubject(t *testing.T) {
|
||||||
|
in := store.RuleInput{
|
||||||
|
Name: "Втрати на магістралі",
|
||||||
|
Source: "icmp",
|
||||||
|
Severity: "disaster",
|
||||||
|
Selector: `{"site_ids":["kyiv-1"]}`,
|
||||||
|
Condition: `{"metric":"loss_pct","op":">","value":5}`,
|
||||||
|
ForSeconds: 300,
|
||||||
|
ChannelIDs: []string{"11111111-1111-4111-8111-111111111111"},
|
||||||
|
EscalationPolicyID: "22222222-2222-4222-8222-222222222222",
|
||||||
|
NotifyOnResolve: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := metaJSON(t, alertRuleAuditMeta(in, false))
|
||||||
|
|
||||||
|
// Предмет правила — те, заради чого запис і читають: правило з тією
|
||||||
|
// ж назвою може ловити вже зовсім не те.
|
||||||
|
if !strings.Contains(got, `"value":5`) || !strings.Contains(got, "loss_pct") {
|
||||||
|
t.Fatalf("умова правила не потрапила в подробиці: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "kyiv-1") {
|
||||||
|
t.Fatalf("селектор не потрапив у подробиці: %s", got)
|
||||||
|
}
|
||||||
|
// Умова має лежати JSON-об'єктом, а не рядком у лапках: інакше
|
||||||
|
// пошук по журналу шукає по екранованому тексту.
|
||||||
|
if strings.Contains(got, `"condition":"`) {
|
||||||
|
t.Fatalf("умова записана рядком, а не JSON: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"enabled":false`) {
|
||||||
|
t.Fatalf("стан «вимкнено» не видно з подробиць: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порожня умова не має валити ВЕСЬ запис: подія без подробиць гірша за
|
||||||
|
// подробиці без одного поля, але подія, якої немає, — найгірша.
|
||||||
|
func TestAlertRuleAuditMetaSurvivesBrokenJSON(t *testing.T) {
|
||||||
|
got := metaJSON(t, alertRuleAuditMeta(store.RuleInput{
|
||||||
|
Name: "Криве", Condition: "{не json", Selector: "",
|
||||||
|
}, true))
|
||||||
|
if !strings.Contains(got, "не json") {
|
||||||
|
t.Fatalf("невалідну умову треба зберегти хоч рядком: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Найважливіше правило зони А: вимкнення видно з НАЗВИ дії, а не з
|
||||||
|
// порівняння подробиць.
|
||||||
|
func TestRuleSaveOutcome(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ref store.RuleRef
|
||||||
|
want ruleSaveKind
|
||||||
|
}{
|
||||||
|
{"вимкнули збереженням форми",
|
||||||
|
store.RuleRef{WasEnabled: true, Enabled: false}, ruleSavedAsDisable},
|
||||||
|
{"увімкнули збереженням форми",
|
||||||
|
store.RuleRef{WasEnabled: false, Enabled: true}, ruleSavedAsEnable},
|
||||||
|
{"правка ввімкненого правила",
|
||||||
|
store.RuleRef{WasEnabled: true, Enabled: true}, ruleSavedAsUpdate},
|
||||||
|
// Головний із чотирьох: збереження вимкненого правила нічого не
|
||||||
|
// гасить (гасіння вже сталось на переході), тож називати це
|
||||||
|
// вимкненням означало б показувати в журналі подію, якої не
|
||||||
|
// було.
|
||||||
|
{"правка вимкненого правила",
|
||||||
|
store.RuleRef{WasEnabled: false, Enabled: false}, ruleSavedAsUpdate},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ruleSaveOutcome(c.ref); got != c.want {
|
||||||
|
t.Errorf("%s: отримано %d, очікувалось %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscalationAuditMetaKeepsSteps(t *testing.T) {
|
||||||
|
got := metaJSON(t, escalationAuditMeta(store.EscalationPolicy{
|
||||||
|
Name: "Нічна",
|
||||||
|
Steps: []store.EscalationStep{
|
||||||
|
{AfterMin: 15, ChannelIDs: []string{"33333333-3333-4333-8333-333333333333"}},
|
||||||
|
},
|
||||||
|
RepeatAfterMin: 30, MaxRepeats: 3, RespectQuietHours: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if !strings.Contains(got, `"after_min":15`) {
|
||||||
|
t.Fatalf("сходинки драбини не потрапили в подробиці: %s", got)
|
||||||
|
}
|
||||||
|
// Тиха година — те саме, що вимкнення правила, тільки для драбини:
|
||||||
|
// вночі вона мовчатиме, тобто резерв не спрацює саме тоді, коли
|
||||||
|
// потрібен.
|
||||||
|
if !strings.Contains(got, `"respect_quiet_hours":true`) {
|
||||||
|
t.Fatalf("тиха година не видна з подробиць: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Драбина без сходинок має дати порожній масив, а не null: null у
|
||||||
|
// журналі читається як «сходинки невідомі», хоч насправді їх немає.
|
||||||
|
func TestEscalationAuditMetaEmptySteps(t *testing.T) {
|
||||||
|
got := metaJSON(t, escalationAuditMeta(store.EscalationPolicy{Name: "Порожня"}))
|
||||||
|
if !strings.Contains(got, `"steps":[]`) {
|
||||||
|
t.Fatalf("порожня драбина записана як null: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComplianceRuleAuditMetaKeepsPattern(t *testing.T) {
|
||||||
|
got := metaJSON(t, complianceRuleAuditMeta(store.ComplianceRule{
|
||||||
|
Name: "Telnet вимкнено", Kind: "forbidden", Pattern: `(?m)^transport input telnet`,
|
||||||
|
Severity: "high", ConfigType: "running", Enabled: false,
|
||||||
|
BuiltinKey: "no-telnet",
|
||||||
|
}))
|
||||||
|
|
||||||
|
if !strings.Contains(got, "transport input telnet") {
|
||||||
|
t.Fatalf("зразок правила не потрапив у подробиці: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"enabled":false`) {
|
||||||
|
t.Fatalf("вимкнення правила не видно з подробиць: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "no-telnet") {
|
||||||
|
t.Fatalf("ознака вбудованого правила загубилась: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Друге правило роботи: пароля в журналі немає ні в якому вигляді.
|
||||||
|
func TestMemberProfileAuditMetaNeverCarriesPassword(t *testing.T) {
|
||||||
|
in := store.UserProfileInput{
|
||||||
|
Username: "petrenko",
|
||||||
|
Email: "petrenko@example.com",
|
||||||
|
Password: userPass,
|
||||||
|
}
|
||||||
|
got := metaJSON(t, memberProfileAuditMeta(in, "petrenko"))
|
||||||
|
|
||||||
|
if strings.Contains(got, userPass) {
|
||||||
|
t.Fatalf("пароль потрапив у подробиці журналу: %s", got)
|
||||||
|
}
|
||||||
|
// Але сам факт має лишитись: без нього скидання пароля не
|
||||||
|
// відрізнити від перейменування.
|
||||||
|
if !strings.Contains(got, `"password"`) {
|
||||||
|
t.Fatalf("факт зміни пароля загубився: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"username"`) || !strings.Contains(got, `"email"`) {
|
||||||
|
t.Fatalf("перелік змінених полів неповний: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порожній рядок у UserProfileInput означає «не чіпати» — і в переліку
|
||||||
|
// змінених полів такого поля бути не повинно.
|
||||||
|
func TestMemberProfileAuditMetaListsOnlyChangedFields(t *testing.T) {
|
||||||
|
got := metaJSON(t, memberProfileAuditMeta(
|
||||||
|
store.UserProfileInput{Password: userPass}, "petrenko"))
|
||||||
|
if strings.Contains(got, "full_name") || strings.Contains(got, `"email"`) {
|
||||||
|
t.Fatalf("у перелік потрапили поля, яких не міняли: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Зона Б цілком: «звідки в цієї людини такий доступ» має відповідь з
|
||||||
|
// обома ролями.
|
||||||
|
func TestMemberRoleAuditMetaHasBothRoles(t *testing.T) {
|
||||||
|
got := metaJSON(t, memberRoleAuditMeta(
|
||||||
|
store.MemberRef{UserID: "u1", Username: "petrenko",
|
||||||
|
RoleID: "r1", RoleKey: "viewer", RoleName: "Спостерігач"},
|
||||||
|
store.MemberRef{UserID: "u1", Username: "petrenko",
|
||||||
|
RoleID: "r2", RoleKey: "admin", RoleName: "Адміністратор"},
|
||||||
|
))
|
||||||
|
|
||||||
|
for _, want := range []string{"petrenko", "viewer", "Спостерігач", "admin", "Адміністратор"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("у записі про зміну ролі немає %q: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONMetaKeepsBrokenJSONAsText(t *testing.T) {
|
||||||
|
if got := jsonMeta(""); got != nil {
|
||||||
|
t.Errorf("порожній JSON має давати nil, а не %v", got)
|
||||||
|
}
|
||||||
|
if _, ok := jsonMeta(`{"a":1}`).(json.RawMessage); !ok {
|
||||||
|
t.Error("валідний JSON має лягати в подробиці як JSON")
|
||||||
|
}
|
||||||
|
if got, ok := jsonMeta("{зламано").(string); !ok || got != "{зламано" {
|
||||||
|
t.Error("зламаний JSON має лягти рядком, а не завалити весь запис")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -340,7 +340,7 @@ func TestRevokedMembershipBlocksToken(t *testing.T) {
|
||||||
t.Fatal("доступ не працював ще до відкликання")
|
t.Fatal("доступ не працював ще до відкликання")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.store.RemoveMember(f.ctx, f.tenantID, userID); err != nil {
|
if _, err := f.store.RemoveMember(f.ctx, f.tenantID, userID); err != nil {
|
||||||
t.Fatalf("видалення членства: %v", err)
|
t.Fatalf("видалення членства: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,23 @@ func (s *Server) handleSaveComplianceRule(w http.ResponseWriter, r *http.Request
|
||||||
in.ConfigType = "running"
|
in.ConfigType = "running"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Створення чи зміна — видно лише зі шляху: збереження повертає
|
||||||
|
// однаковий id в обох випадках, і після нього відрізнити їх уже
|
||||||
|
// нічим.
|
||||||
|
existing := in.ID != ""
|
||||||
|
|
||||||
id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in)
|
id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeStoreError(w, "правило відповідності", err)
|
s.writeStoreError(w, "правило відповідності", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if existing {
|
||||||
|
s.audit(r, p, store.AuditActionComplianceRuleUpdate, store.AuditObjectComplianceRule, id,
|
||||||
|
complianceRuleAuditMeta(in))
|
||||||
|
} else {
|
||||||
|
s.audit(r, p, store.AuditActionComplianceRuleCreate, store.AuditObjectComplianceRule, id,
|
||||||
|
complianceRuleAuditMeta(in))
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,10 +120,16 @@ func (s *Server) handleDeleteComplianceRule(w http.ResponseWriter, r *http.Reque
|
||||||
if !requirePerm(w, p, "ncm:write") {
|
if !requirePerm(w, p, "ncm:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.DeleteComplianceRule(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
|
ref, err := s.store.DeleteComplianceRule(r.Context(), p.TenantID, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
s.writeStoreError(w, "правило відповідності", err)
|
s.writeStoreError(w, "правило відповідності", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Зразок їде в подробиці саме на видаленні: після нього вимоги
|
||||||
|
// немає ніде, і питання «а що ми взагалі перевіряли» лишилось би
|
||||||
|
// без відповіді назавжди. Та сама причина, що у видаленні ролі.
|
||||||
|
s.audit(r, p, store.AuditActionComplianceRuleDelete, store.AuditObjectComplianceRule, ref.ID,
|
||||||
|
map[string]any{"name": ref.Name, "severity": ref.Severity, "pattern": ref.Pattern})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,12 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
|
||||||
s.writeStoreError(w, "створення користувача", err)
|
s.writeStoreError(w, "створення користувача", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Роль пишемо ключем, а не ідентифікатором: ключ прийшов у запиті,
|
||||||
|
// і саме він потім стоятиме в питанні «хто дав цій людині admin».
|
||||||
|
// Пароля в подробицях немає й бути не може — ні тут, ні в зміні
|
||||||
|
// профілю нижче.
|
||||||
|
s.audit(r, p, store.AuditActionMemberAdd, store.AuditObjectMember, u.ID,
|
||||||
|
map[string]any{"username": u.Username, "role_key": in.RoleKey})
|
||||||
writeJSON(w, http.StatusCreated, u)
|
writeJSON(w, http.StatusCreated, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,12 +147,13 @@ func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Princi
|
||||||
}
|
}
|
||||||
|
|
||||||
if in.Username != "" || in.Email != "" || in.FullName != "" || in.Password != "" {
|
if in.Username != "" || in.Email != "" || in.FullName != "" || in.Password != "" {
|
||||||
err := s.store.UpdateUserProfile(r.Context(), p.TenantID, userID, store.UserProfileInput{
|
profile := store.UserProfileInput{
|
||||||
Username: in.Username,
|
Username: in.Username,
|
||||||
Email: in.Email,
|
Email: in.Email,
|
||||||
FullName: in.FullName,
|
FullName: in.FullName,
|
||||||
Password: in.Password,
|
Password: in.Password,
|
||||||
})
|
}
|
||||||
|
username, err := s.store.UpdateUserProfile(r.Context(), p.TenantID, userID, profile)
|
||||||
if errors.Is(err, store.ErrSharedUser) {
|
if errors.Is(err, store.ErrSharedUser) {
|
||||||
writeError(w, http.StatusConflict, "shared_user",
|
writeError(w, http.StatusConflict, "shared_user",
|
||||||
"цей акаунт працює ще в іншій організації — профіль і пароль "+
|
"цей акаунт працює ще в іншій організації — профіль і пароль "+
|
||||||
|
|
@ -165,10 +172,20 @@ func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Princi
|
||||||
s.writeStoreError(w, "оновлення профілю", err)
|
s.writeStoreError(w, "оновлення профілю", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Скидання пароля перебиває решту полів: це найдорожчий факт
|
||||||
|
// запиту, і його має бути видно з назви дії, а не з переліку
|
||||||
|
// змінених полів усередині подробиць.
|
||||||
|
meta := memberProfileAuditMeta(profile, username)
|
||||||
|
if profile.Password != "" {
|
||||||
|
s.audit(r, p, store.AuditActionMemberPassword, store.AuditObjectMember, userID, meta)
|
||||||
|
} else {
|
||||||
|
s.audit(r, p, store.AuditActionMemberProfile, store.AuditObjectMember, userID, meta)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if in.RoleID != "" {
|
if in.RoleID != "" {
|
||||||
if err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID); err != nil {
|
before, after, err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID)
|
||||||
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "учасника або роль не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "учасника або роль не знайдено")
|
||||||
return
|
return
|
||||||
|
|
@ -176,6 +193,21 @@ func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Princi
|
||||||
s.writeStoreError(w, "зміна ролі", err)
|
s.writeStoreError(w, "зміна ролі", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Роль, яка не змінилась, у журнал не йде. Форма команди шле
|
||||||
|
// role_id завжди — навіть коли правили лише ім'я, — і рядок
|
||||||
|
// «змінив роль з Адміністратора на Адміністратора» був би не
|
||||||
|
// подією, а шумом рівно в тому переліку, який відкривають, щоб
|
||||||
|
// подію знайти.
|
||||||
|
//
|
||||||
|
// Окремий запис, а не поле в правці профілю вище, хоч форма й
|
||||||
|
// одна: це різні події для різних читачів. Профіль питають про
|
||||||
|
// «хто перейменував», роль — про «звідки в цієї людини такий
|
||||||
|
// доступ», і зводити їх в один рядок означало б, що друге
|
||||||
|
// питання доводиться шукати всередині відповіді на перше.
|
||||||
|
if before.RoleID != after.RoleID {
|
||||||
|
s.audit(r, p, store.AuditActionMemberRole, store.AuditObjectMember, userID,
|
||||||
|
memberRoleAuditMeta(before, after))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
@ -192,7 +224,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, p *P
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.store.RemoveMember(r.Context(), p.TenantID, userID)
|
gone, err := s.store.RemoveMember(r.Context(), p.TenantID, userID)
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено")
|
writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено")
|
||||||
return
|
return
|
||||||
|
|
@ -201,5 +233,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, p *P
|
||||||
s.writeStoreError(w, "видалення учасника", err)
|
s.writeStoreError(w, "видалення учасника", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Роль на момент вилучення — не прикраса: рядок membership зникає
|
||||||
|
// разом із нею, а питання «що ця людина тут могла» ставлять уже
|
||||||
|
// після того, як її прибрали.
|
||||||
|
s.audit(r, p, store.AuditActionMemberRemove, store.AuditObjectMember, gone.UserID,
|
||||||
|
map[string]any{
|
||||||
|
"username": gone.Username,
|
||||||
|
"role_key": gone.RoleKey, "role_name": gone.RoleName,
|
||||||
|
})
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ func TestChannelRefsAgainstDB(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Видалення каналу чистить сходинки -----------------------------
|
// --- Видалення каналу чистить сходинки -----------------------------
|
||||||
if err := st.DeleteChannel(ctx, ours, duty); err != nil {
|
if _, err := st.DeleteChannel(ctx, ours, duty); err != nil {
|
||||||
t.Fatalf("видалення каналу: %v", err)
|
t.Fatalf("видалення каналу: %v", err)
|
||||||
}
|
}
|
||||||
ps, err := st.ListEscalationPolicies(ctx, ours)
|
ps, err := st.ListEscalationPolicies(ctx, ours)
|
||||||
|
|
|
||||||
|
|
@ -433,8 +433,34 @@ func ladderChannelIDs(steps []EscalationStep) []string {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error {
|
// ChannelRef — те, що дія запису розповідає про канал журналу аудиту.
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
//
|
||||||
|
// Ні секрету, ні config тут немає навмисно: у journal їде рівно те, що
|
||||||
|
// названо в цій структурі, і сама її форма — перешкода для наступного,
|
||||||
|
// хто спробує «додати ще й адресу вебхука, вона ж не пароль».
|
||||||
|
type ChannelRef struct {
|
||||||
|
ID string
|
||||||
|
Kind string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) (ChannelRef, error) {
|
||||||
|
ref := ChannelRef{ID: channelID}
|
||||||
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
// Вид і назву читаємо до видалення, у тій самій транзакції:
|
||||||
|
// після DELETE відповісти на «який саме канал зник» нема чим, а
|
||||||
|
// «канал 7f3a…» у журналі не відповідає ні на що.
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT kind::text, name FROM alr.channels
|
||||||
|
WHERE tenant_id = $1 AND id = $2
|
||||||
|
FOR UPDATE
|
||||||
|
`, tenantID, channelID).Scan(&ref.Kind, &ref.Name); err != nil {
|
||||||
|
if isNoRows(err) {
|
||||||
|
return ErrAlertNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Секрет видаляється разом із каналом: залишений «на всякий
|
// Секрет видаляється разом із каналом: залишений «на всякий
|
||||||
// випадок» токен у core.secrets — це чинний доступ, про який
|
// випадок» токен у core.secrets — це чинний доступ, про який
|
||||||
// уже ніхто не пам'ятає.
|
// уже ніхто не пам'ятає.
|
||||||
|
|
@ -529,6 +555,7 @@ func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) e
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return ref, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuleAction — куди й коли шле саме це правило.
|
// RuleAction — куди й коли шле саме це правило.
|
||||||
|
|
|
||||||
|
|
@ -265,8 +265,39 @@ func (s *Store) SaveEscalationPolicy(ctx context.Context, tenantID, id string, p
|
||||||
// видалено». Мовчазного продовження за старою копією немає навмисно:
|
// видалено». Мовчазного продовження за старою копією немає навмисно:
|
||||||
// драбина, якої вже немає у формі, але яка ще будить людей, — найгірший
|
// драбина, якої вже немає у формі, але яка ще будить людей, — найгірший
|
||||||
// із можливих станів.
|
// із можливих станів.
|
||||||
func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string) error {
|
// EscalationRef — те, що дія запису розповідає про драбину журналу
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
// аудиту.
|
||||||
|
//
|
||||||
|
// RuleCount тут не для повноти: рівно стільки правил цієї ж миті
|
||||||
|
// лишились без ескалації (ON DELETE SET NULL у 0066). Без цього числа
|
||||||
|
// видалення драбини читається як прибирання зайвого рядка, а насправді
|
||||||
|
// це зняття резервного механізму з десятка тригерів — і дізнатись про
|
||||||
|
// це постфактум нема звідки, бо в самих правилах escalation_policy_id
|
||||||
|
// уже порожній.
|
||||||
|
type EscalationRef struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
RuleCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string) (EscalationRef, error) {
|
||||||
|
ref := EscalationRef{ID: id}
|
||||||
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
|
// Назва й лічильник правил — до видалення й у тій самій
|
||||||
|
// транзакції: після DELETE обидва вже нічиї.
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT p.name,
|
||||||
|
(SELECT count(*)::int FROM alr.rules r
|
||||||
|
WHERE r.escalation_policy_id = p.id)
|
||||||
|
FROM alr.escalation_policies p
|
||||||
|
WHERE p.tenant_id = $1 AND p.id = $2
|
||||||
|
FOR UPDATE OF p
|
||||||
|
`, tenantID, id).Scan(&ref.Name, &ref.RuleCount); err != nil {
|
||||||
|
if isNoRows(err) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
ct, err := tx.Exec(ctx,
|
ct, err := tx.Exec(ctx,
|
||||||
`DELETE FROM alr.escalation_policies WHERE tenant_id = $1 AND id = $2`, tenantID, id)
|
`DELETE FROM alr.escalation_policies WHERE tenant_id = $1 AND id = $2`, tenantID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -277,6 +308,7 @@ func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return ref, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -510,8 +510,26 @@ func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in Rule
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) error {
|
// RuleRef — те, що дія запису розповідає про правило журналу аудиту.
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
//
|
||||||
|
// Повертається з самої дії, а не читається окремим запитом поруч, з
|
||||||
|
// двох причин. Після DELETE назву взяти вже нізвідки — а запис «видалено
|
||||||
|
// правило 7f3a…» не відповідає на питання, заради якого журнал і
|
||||||
|
// відкривають. І перехід «було ввімкнене → стало вимкнене» видно лише
|
||||||
|
// зсередини транзакції: окремий SELECT до неї побачив би стан, який до
|
||||||
|
// моменту запису вже нічий.
|
||||||
|
type RuleRef struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
// WasEnabled — стан ДО дії, Enabled — після. Журнал відрізняє
|
||||||
|
// вимкнення правила від звичайної правки саме за цією парою.
|
||||||
|
WasEnabled bool
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) (RuleRef, error) {
|
||||||
|
var ref RuleRef
|
||||||
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
// Спершу закриваємо алерти, і лише потім саме правило.
|
// Спершу закриваємо алерти, і лише потім саме правило.
|
||||||
//
|
//
|
||||||
// Порядок принциповий: alr.alerts.rule_id має ON DELETE SET NULL,
|
// Порядок принциповий: alr.alerts.rule_id має ON DELETE SET NULL,
|
||||||
|
|
@ -521,29 +539,41 @@ func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) error {
|
||||||
if err := resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило видалено"); err != nil {
|
if err := resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило видалено"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tag, err := tx.Exec(ctx,
|
err := tx.QueryRow(ctx, `
|
||||||
`DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2`, tenantID, ruleID)
|
DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2
|
||||||
if err != nil {
|
RETURNING id::text, name, enabled
|
||||||
return err
|
`, tenantID, ruleID).Scan(&ref.ID, &ref.Name, &ref.WasEnabled)
|
||||||
}
|
if isNoRows(err) {
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return ErrAlertNotFound
|
return ErrAlertNotFound
|
||||||
}
|
}
|
||||||
return nil
|
return err
|
||||||
})
|
})
|
||||||
|
return ref, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) error {
|
func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) (RuleRef, error) {
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
ref := RuleRef{ID: ruleID, Enabled: enabled}
|
||||||
tag, err := tx.Exec(ctx, `
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
UPDATE alr.rules SET enabled = $3, updated_at = now()
|
// Читання під замком у тій же транзакції, що й запис, — з тієї
|
||||||
|
// ж причини, що в UpdateRule нижче: між читанням і UPDATE
|
||||||
|
// уміщається збереження форми правила, і без FOR UPDATE журнал
|
||||||
|
// назвав би дією те, чого не сталось.
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT name, enabled FROM alr.rules
|
||||||
WHERE tenant_id = $1 AND id = $2
|
WHERE tenant_id = $1 AND id = $2
|
||||||
`, tenantID, ruleID, enabled)
|
FOR UPDATE
|
||||||
|
`, tenantID, ruleID).Scan(&ref.Name, &ref.WasEnabled)
|
||||||
|
if isNoRows(err) {
|
||||||
|
return ErrAlertNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if _, err := tx.Exec(ctx, `
|
||||||
return ErrAlertNotFound
|
UPDATE alr.rules SET enabled = $3, updated_at = now()
|
||||||
|
WHERE tenant_id = $1 AND id = $2
|
||||||
|
`, tenantID, ruleID, enabled); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
// Вимкнене правило випадає з вибірки движка, тому закрити свої
|
// Вимкнене правило випадає з вибірки движка, тому закрити свої
|
||||||
// алерти вже не зможе — це треба зробити тут і зараз.
|
// алерти вже не зможе — це треба зробити тут і зараз.
|
||||||
|
|
@ -552,6 +582,7 @@ func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, ena
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return ref, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveRuleAlerts закриває всі активні алерти правила й повідомляє UI.
|
// resolveRuleAlerts закриває всі активні алерти правила й повідомляє UI.
|
||||||
|
|
@ -628,8 +659,9 @@ func planRuleEnabled(current bool, want *bool) (enabled, resolve bool) {
|
||||||
//
|
//
|
||||||
// Цілком, а не полями: форма показує повний стан правила, і часткові
|
// Цілком, а не полями: форма показує повний стан правила, і часткові
|
||||||
// оновлення дали б спосіб отримати комбінацію, якої людина не бачила.
|
// оновлення дали б спосіб отримати комбінацію, якої людина не бачила.
|
||||||
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) error {
|
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) (RuleRef, error) {
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
ref := RuleRef{ID: ruleID, Name: in.Name}
|
||||||
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
// Стан ДО правки читається окремо й під замком.
|
// Стан ДО правки читається окремо й під замком.
|
||||||
//
|
//
|
||||||
// Окремо — бо після UPDATE його вже не відновити, а рішення про
|
// Окремо — бо після UPDATE його вже не відновити, а рішення про
|
||||||
|
|
@ -651,6 +683,7 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
enabled, resolve := planRuleEnabled(was, in.Enabled)
|
enabled, resolve := planRuleEnabled(was, in.Enabled)
|
||||||
|
ref.WasEnabled, ref.Enabled = was, enabled
|
||||||
|
|
||||||
// Канали перевіряємо ПІСЛЯ того, як переконались, що правило
|
// Канали перевіряємо ПІСЛЯ того, як переконались, що правило
|
||||||
// існує: інакше правка чужого правила з поганим каналом сказала
|
// існує: інакше правка чужого правила з поганим каналом сказала
|
||||||
|
|
@ -703,4 +736,5 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return ref, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ func TestRuleChannelsAgainstDB(t *testing.T) {
|
||||||
// Окремий випадок навмисно: перевірка лише на створенні лишала б
|
// Окремий випадок навмисно: перевірка лише на створенні лишала б
|
||||||
// двері відчиненими — правило заводять справним, а ламають правкою.
|
// двері відчиненими — правило заводять справним, а ламають правкою.
|
||||||
upd := rule("Ядро не відповідає", duty, alien)
|
upd := rule("Ядро не відповідає", duty, alien)
|
||||||
if err := st.UpdateRule(ctx, ours, ruleID, upd); err == nil {
|
if _, err := st.UpdateRule(ctx, ours, ruleID, upd); err == nil {
|
||||||
t.Fatal("правка підставила канал чужого кабінету")
|
t.Fatal("правка підставила канал чужого кабінету")
|
||||||
} else if !errors.Is(err, ErrInvalid) {
|
} else if !errors.Is(err, ErrInvalid) {
|
||||||
t.Fatalf("відмова правки має бути ErrInvalid: %v", err)
|
t.Fatalf("відмова правки має бути ErrInvalid: %v", err)
|
||||||
|
|
@ -161,7 +161,7 @@ func TestRuleChannelsAgainstDB(t *testing.T) {
|
||||||
// Порядок перевірок відповідає на те питання, яке поставили: правка
|
// Порядок перевірок відповідає на те питання, яке поставили: правка
|
||||||
// чужого (або видаленого) правила має казати «правило не знайдено»,
|
// чужого (або видаленого) правила має казати «правило не знайдено»,
|
||||||
// а не звітувати про стан каналів у ньому.
|
// а не звітувати про стан каналів у ньому.
|
||||||
err = st.UpdateRule(ctx, ours, "00000000-0000-4000-8000-0000000000fd", upd)
|
_, err = st.UpdateRule(ctx, ours, "00000000-0000-4000-8000-0000000000fd", upd)
|
||||||
if !errors.Is(err, ErrNotFound) {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("правка неіснуючого правила має давати ErrNotFound: %v", err)
|
t.Fatalf("правка неіснуючого правила має давати ErrNotFound: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +184,7 @@ func TestRuleChannelsAgainstDB(t *testing.T) {
|
||||||
//
|
//
|
||||||
// Симетрично сходинкам драбини: залишений UUID видаленого каналу —
|
// Симетрично сходинкам драбини: залишений UUID видаленого каналу —
|
||||||
// це правило, яке виглядає налаштованим і не шле нікуди.
|
// це правило, яке виглядає налаштованим і не шле нікуди.
|
||||||
if err := st.DeleteChannel(ctx, ours, duty); err != nil {
|
if _, err := st.DeleteChannel(ctx, ours, duty); err != nil {
|
||||||
t.Fatalf("видалення каналу: %v", err)
|
t.Fatalf("видалення каналу: %v", err)
|
||||||
}
|
}
|
||||||
got := ruleChannels(ours, ruleID)
|
got := ruleChannels(ours, ruleID)
|
||||||
|
|
@ -211,7 +211,7 @@ func TestRuleChannelsAgainstDB(t *testing.T) {
|
||||||
// маршрутів кабінету. Це його ж типовий стан і він гучніший за
|
// маршрутів кабінету. Це його ж типовий стан і він гучніший за
|
||||||
// порожню розсилку — тому канали саме прибираються, а не лишаються
|
// порожню розсилку — тому канали саме прибираються, а не лишаються
|
||||||
// мертвими.
|
// мертвими.
|
||||||
if err := st.DeleteChannel(ctx, ours, lead); err != nil {
|
if _, err := st.DeleteChannel(ctx, ours, lead); err != nil {
|
||||||
t.Fatalf("видалення другого каналу: %v", err)
|
t.Fatalf("видалення другого каналу: %v", err)
|
||||||
}
|
}
|
||||||
if got := ruleChannels(ours, ruleID); len(got) != 0 {
|
if got := ruleChannels(ours, ruleID); len(got) != 0 {
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
|
||||||
|
|
||||||
in := base(slug + "-перше")
|
in := base(slug + "-перше")
|
||||||
in.Description = "правку опису самої по собі мало б бути видно лише в описі"
|
in.Description = "правку опису самої по собі мало б бути видно лише в описі"
|
||||||
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
if _, err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
||||||
t.Fatalf("правка без зміни стану: %v", err)
|
t.Fatalf("правка без зміни стану: %v", err)
|
||||||
}
|
}
|
||||||
if !ruleEnabled(r1) {
|
if !ruleEnabled(r1) {
|
||||||
|
|
@ -142,7 +142,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
in.Enabled = &off
|
in.Enabled = &off
|
||||||
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
if _, err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
||||||
t.Fatalf("вимкнення правкою: %v", err)
|
t.Fatalf("вимкнення правкою: %v", err)
|
||||||
}
|
}
|
||||||
if ruleEnabled(r1) {
|
if ruleEnabled(r1) {
|
||||||
|
|
@ -168,7 +168,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
|
||||||
// Повторне збереження вже вимкненого правила не має додавати ще
|
// Повторне збереження вже вимкненого правила не має додавати ще
|
||||||
// одну подію: гасити нічого, а зайвий запис у чергу — це зайве
|
// одну подію: гасити нічого, а зайвий запис у чергу — це зайве
|
||||||
// сповіщення «відновлено» тим, хто підписаний на канал.
|
// сповіщення «відновлено» тим, хто підписаний на канал.
|
||||||
if err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
if _, err := st.UpdateRule(ctx, tenantID, r1, in); err != nil {
|
||||||
t.Fatalf("повторна правка вимкненого: %v", err)
|
t.Fatalf("повторна правка вимкненого: %v", err)
|
||||||
}
|
}
|
||||||
if err := st.pool.QueryRow(ctx, `
|
if err := st.pool.QueryRow(ctx, `
|
||||||
|
|
@ -191,7 +191,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
|
||||||
|
|
||||||
in2 := base(slug + "-друге")
|
in2 := base(slug + "-друге")
|
||||||
in2.Description = "поправили опис вимкненого правила"
|
in2.Description = "поправили опис вимкненого правила"
|
||||||
if err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
|
if _, err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
|
||||||
t.Fatalf("правка вимкненого правила: %v", err)
|
t.Fatalf("правка вимкненого правила: %v", err)
|
||||||
}
|
}
|
||||||
if ruleEnabled(r2) {
|
if ruleEnabled(r2) {
|
||||||
|
|
@ -201,7 +201,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
|
||||||
|
|
||||||
// І другий бік тієї ж пари: явне `true` мусить вмикати.
|
// І другий бік тієї ж пари: явне `true` мусить вмикати.
|
||||||
in2.Enabled = &on
|
in2.Enabled = &on
|
||||||
if err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
|
if _, err := st.UpdateRule(ctx, tenantID, r2, in2); err != nil {
|
||||||
t.Fatalf("увімкнення правкою: %v", err)
|
t.Fatalf("увімкнення правкою: %v", err)
|
||||||
}
|
}
|
||||||
if !ruleEnabled(r2) {
|
if !ruleEnabled(r2) {
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,34 @@ const (
|
||||||
AuditActionMirrorUpdate = "ncm.mirror.update"
|
AuditActionMirrorUpdate = "ncm.mirror.update"
|
||||||
AuditActionMirrorPush = "ncm.mirror.push"
|
AuditActionMirrorPush = "ncm.mirror.push"
|
||||||
|
|
||||||
|
AuditActionAlertRuleCreate = "alr.rule.create"
|
||||||
|
AuditActionAlertRuleUpdate = "alr.rule.update"
|
||||||
|
AuditActionAlertRuleDelete = "alr.rule.delete"
|
||||||
|
AuditActionAlertRuleEnable = "alr.rule.enable"
|
||||||
|
AuditActionAlertRuleDisable = "alr.rule.disable"
|
||||||
|
|
||||||
|
AuditActionChannelCreate = "alr.channel.create"
|
||||||
|
AuditActionChannelUpdate = "alr.channel.update"
|
||||||
|
AuditActionChannelDelete = "alr.channel.delete"
|
||||||
|
|
||||||
|
AuditActionEscalationCreate = "alr.escalation_policy.create"
|
||||||
|
AuditActionEscalationUpdate = "alr.escalation_policy.update"
|
||||||
|
AuditActionEscalationDelete = "alr.escalation_policy.delete"
|
||||||
|
|
||||||
|
AuditActionComplianceRuleCreate = "ncm.compliance_rule.create"
|
||||||
|
AuditActionComplianceRuleUpdate = "ncm.compliance_rule.update"
|
||||||
|
AuditActionComplianceRuleDelete = "ncm.compliance_rule.delete"
|
||||||
|
|
||||||
AuditActionRoleCreate = "core.role.create"
|
AuditActionRoleCreate = "core.role.create"
|
||||||
AuditActionRoleUpdate = "core.role.update"
|
AuditActionRoleUpdate = "core.role.update"
|
||||||
AuditActionRoleDelete = "core.role.delete"
|
AuditActionRoleDelete = "core.role.delete"
|
||||||
|
|
||||||
|
AuditActionMemberAdd = "core.member.add"
|
||||||
|
AuditActionMemberRole = "core.member.role"
|
||||||
|
AuditActionMemberRemove = "core.member.remove"
|
||||||
|
AuditActionMemberProfile = "core.member.profile"
|
||||||
|
AuditActionMemberPassword = "core.member.password"
|
||||||
|
|
||||||
AuditActionTelegramLink = "core.telegram.link"
|
AuditActionTelegramLink = "core.telegram.link"
|
||||||
AuditActionTelegramUnlink = "core.telegram.unlink"
|
AuditActionTelegramUnlink = "core.telegram.unlink"
|
||||||
|
|
||||||
|
|
@ -90,7 +114,12 @@ const (
|
||||||
AuditObjectConfig = "ncm.config"
|
AuditObjectConfig = "ncm.config"
|
||||||
AuditObjectBackupDefaults = "ncm.backup_defaults"
|
AuditObjectBackupDefaults = "ncm.backup_defaults"
|
||||||
AuditObjectMirror = "ncm.mirror"
|
AuditObjectMirror = "ncm.mirror"
|
||||||
|
AuditObjectComplianceRule = "ncm.compliance_rule"
|
||||||
|
AuditObjectAlertRule = "alr.rule"
|
||||||
|
AuditObjectChannel = "alr.channel"
|
||||||
|
AuditObjectEscalation = "alr.escalation_policy"
|
||||||
AuditObjectRole = "core.role"
|
AuditObjectRole = "core.role"
|
||||||
|
AuditObjectMember = "core.member"
|
||||||
AuditObjectTelegram = "core.telegram_account"
|
AuditObjectTelegram = "core.telegram_account"
|
||||||
AuditObjectRetention = "core.retention_settings"
|
AuditObjectRetention = "core.retention_settings"
|
||||||
AuditObjectSLATarget = "core.sla_target"
|
AuditObjectSLATarget = "core.sla_target"
|
||||||
|
|
@ -113,8 +142,10 @@ type AuditActionInfo struct {
|
||||||
//
|
//
|
||||||
// Перелік і є вся правда про покриття: аудит пишуть масові дії над
|
// Перелік і є вся правда про покриття: аудит пишуть масові дії над
|
||||||
// хостами, виконання команд, руйнівні дії над архівом конфігів,
|
// хостами, виконання команд, руйнівні дії над архівом конфігів,
|
||||||
// налаштування дзеркала Git і зміни складу ролей. Решта продукту в
|
// налаштування дзеркала Git, правила відповідності, вся алертна
|
||||||
// журнал не пише нічого — див. AuditBlindSpots.
|
// частина (правила, канали, драбини ескалації), зміни складу ролей і
|
||||||
|
// склад команди. Решта продукту в журнал не пише нічого — див.
|
||||||
|
// AuditBlindSpots.
|
||||||
var auditActions = []AuditActionInfo{
|
var auditActions = []AuditActionInfo{
|
||||||
{
|
{
|
||||||
Key: AuditActionDeviceBulkUpdate, Group: "Інвентар",
|
Key: AuditActionDeviceBulkUpdate, Group: "Інвентар",
|
||||||
|
|
@ -213,6 +244,90 @@ var auditActions = []AuditActionInfo{
|
||||||
Key: AuditActionMirrorPush, Group: "Конфігурації",
|
Key: AuditActionMirrorPush, Group: "Конфігурації",
|
||||||
Label: "Примусовий пуш архіву на дзеркало",
|
Label: "Примусовий пуш архіву на дзеркало",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionComplianceRuleCreate, Group: "Конфігурації",
|
||||||
|
Label: "Створення правила відповідності",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionComplianceRuleUpdate, Group: "Конфігурації",
|
||||||
|
// Зразок правила їде в meta повністю, і саме він тут головний.
|
||||||
|
// Правило «Telnet вимкнено», якому непомітно послабили зразок,
|
||||||
|
// далі показує зелене — тобто відповідає «вимогу дотримано» на
|
||||||
|
// вимогу, якої вже немає. Вимкнення теж лягає сюди полем
|
||||||
|
// enabled, а не окремою дією: вимкнене правило лишається
|
||||||
|
// видимим у списку й окремим розділом у звіті (див. шапку
|
||||||
|
// DeleteComplianceRule), тож факт «вимогу знято» видно й без
|
||||||
|
// журналу — на відміну від алертного правила, яке зі списку
|
||||||
|
// нікуди не дінеться, але перестане будити.
|
||||||
|
Label: "Зміна правила відповідності",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionComplianceRuleDelete, Group: "Конфігурації",
|
||||||
|
// Найдорожчий рядок розділу. Кабінет, у якому правило стерли,
|
||||||
|
// виглядає у звіті рівно як кабінет, де вимогу скрізь виконано:
|
||||||
|
// ні порушень, ні згадки, що питання взагалі ставили.
|
||||||
|
Label: "Видалення правила відповідності", Destructive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionAlertRuleCreate, Group: "Сповіщення",
|
||||||
|
Label: "Створення правила алертів",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionAlertRuleUpdate, Group: "Сповіщення",
|
||||||
|
Label: "Зміна правила алертів",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionAlertRuleDelete, Group: "Сповіщення",
|
||||||
|
Label: "Видалення правила алертів", Destructive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionAlertRuleDisable, Group: "Сповіщення",
|
||||||
|
// Окрема дія, а не поле в «Зміні правила», і це головне рішення
|
||||||
|
// розділу. Питання, заради якого сюди приходять, звучить «хто
|
||||||
|
// вимкнув правило, за яким приходив алерт», а не «яке поле
|
||||||
|
// змінилось із сімнадцяти»: вимкнення гасить усі активні алерти
|
||||||
|
// правила й прибирає його з вибірки движка, тобто мережа
|
||||||
|
// замовкає рівно в тому місці, де на неї покладались. Шукати це
|
||||||
|
// звірянням двох знімків meta означало б знати заздалегідь, що
|
||||||
|
// саме сталось.
|
||||||
|
Label: "Вимкнення правила алертів",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionAlertRuleEnable, Group: "Сповіщення",
|
||||||
|
Label: "Увімкнення правила алертів",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionChannelCreate, Group: "Сповіщення",
|
||||||
|
Label: "Створення каналу сповіщень",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionChannelUpdate, Group: "Сповіщення",
|
||||||
|
// Ні токена бота, ні вмісту config у meta немає й не буде:
|
||||||
|
// config каналу несе адресу вебхука та довільні заголовки, а це
|
||||||
|
// такий самий секрет, як токен (див. channelAuditMeta).
|
||||||
|
Label: "Зміна каналу сповіщень",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionChannelDelete, Group: "Сповіщення",
|
||||||
|
Label: "Видалення каналу сповіщень", Destructive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionEscalationCreate, Group: "Сповіщення",
|
||||||
|
Label: "Створення драбини ескалації",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionEscalationUpdate, Group: "Сповіщення",
|
||||||
|
Label: "Зміна драбини ескалації",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionEscalationDelete, Group: "Сповіщення",
|
||||||
|
// У meta їде rule_count — скільки правил лишилось без ескалації
|
||||||
|
// цієї ж миті (ON DELETE SET NULL у 0066). Без цього числа
|
||||||
|
// видалення драбини виглядає як прибирання невикористаного
|
||||||
|
// рядка, а насправді це мовчазне зняття резервного механізму з
|
||||||
|
// десятка тригерів.
|
||||||
|
Label: "Видалення драбини ескалації", Destructive: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Key: AuditActionRoleCreate, Group: "Адміністрування",
|
Key: AuditActionRoleCreate, Group: "Адміністрування",
|
||||||
Label: "Створення ролі",
|
Label: "Створення ролі",
|
||||||
|
|
@ -225,6 +340,44 @@ var auditActions = []AuditActionInfo{
|
||||||
Key: AuditActionRoleDelete, Group: "Адміністрування",
|
Key: AuditActionRoleDelete, Group: "Адміністрування",
|
||||||
Label: "Видалення ролі", Destructive: true,
|
Label: "Видалення ролі", Destructive: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionMemberAdd, Group: "Адміністрування",
|
||||||
|
// Друга половина відповіді на «звідки в цієї людини такий
|
||||||
|
// доступ». Перша (що дозволяє роль) уже була в core.role.*, і
|
||||||
|
// сама по собі вона не відповідає ні на що: права без імені —
|
||||||
|
// це опис можливості, а не факт її видачі.
|
||||||
|
Label: "Додавання людини в кабінет",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionMemberRole, Group: "Адміністрування",
|
||||||
|
// У meta стоять обидві ролі, «з» і «на», разом із ключем і
|
||||||
|
// назвою кожної. Ключ — бо за ним шукають, назва — бо через рік
|
||||||
|
// роль може називатись інакше або не існувати зовсім.
|
||||||
|
Label: "Зміна ролі учасника",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionMemberRemove, Group: "Адміністрування",
|
||||||
|
// Destructive: членства більше немає, сесії в цьому кабінеті
|
||||||
|
// відкликані тією ж транзакцією. Сам обліковий запис лишається
|
||||||
|
// (людина може працювати в іншому кабінеті) — саме тому в meta
|
||||||
|
// їде роль, яку вилучений мав на момент вилучення: після
|
||||||
|
// видалення рядка memberships її не відновити нізвідки.
|
||||||
|
Label: "Вилучення людини з кабінету", Destructive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionMemberProfile, Group: "Адміністрування",
|
||||||
|
Label: "Зміна профілю учасника адміністратором",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: AuditActionMemberPassword, Group: "Адміністрування",
|
||||||
|
// Окрема дія з тієї ж причини, що й вимкнення правила: факт має
|
||||||
|
// бути видно з назви. Адміністратор, який змінив людині пароль,
|
||||||
|
// відкликав їй усі сесії й тепер знає, чим зайти під її іменем;
|
||||||
|
// далі в журналі підуть дії від ЇЇ облікового запису, і зв'язати
|
||||||
|
// їх із тим, хто пароль поставив, можна лише через цей рядок.
|
||||||
|
// Самого пароля в meta немає — ні старого, ні нового.
|
||||||
|
Label: "Зміна пароля учасника адміністратором",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Key: AuditActionTelegramLink, Group: "Адміністрування",
|
Key: AuditActionTelegramLink, Group: "Адміністрування",
|
||||||
// Прив'язка не видає нових прав, але дає новий СПОСІБ ними
|
// Прив'язка не видає нових прав, але дає новий СПОСІБ ними
|
||||||
|
|
@ -325,7 +478,12 @@ var auditObjectTypes = []AuditActionInfo{
|
||||||
{Key: AuditObjectConfig, Label: "Версія конфігу"},
|
{Key: AuditObjectConfig, Label: "Версія конфігу"},
|
||||||
{Key: AuditObjectBackupDefaults, Label: "Налаштування бекапів"},
|
{Key: AuditObjectBackupDefaults, Label: "Налаштування бекапів"},
|
||||||
{Key: AuditObjectMirror, Label: "Дзеркало Git"},
|
{Key: AuditObjectMirror, Label: "Дзеркало Git"},
|
||||||
|
{Key: AuditObjectComplianceRule, Label: "Правило відповідності"},
|
||||||
|
{Key: AuditObjectAlertRule, Label: "Правило алертів"},
|
||||||
|
{Key: AuditObjectChannel, Label: "Канал сповіщень"},
|
||||||
|
{Key: AuditObjectEscalation, Label: "Драбина ескалації"},
|
||||||
{Key: AuditObjectRole, Label: "Роль"},
|
{Key: AuditObjectRole, Label: "Роль"},
|
||||||
|
{Key: AuditObjectMember, Label: "Учасник кабінету"},
|
||||||
{Key: AuditObjectTelegram, Label: "Прив'язка Telegram"},
|
{Key: AuditObjectTelegram, Label: "Прив'язка Telegram"},
|
||||||
{Key: AuditObjectRetention, Label: "Строки зберігання даних"},
|
{Key: AuditObjectRetention, Label: "Строки зберігання даних"},
|
||||||
{Key: AuditObjectSLATarget, Label: "Ціль SLA"},
|
{Key: AuditObjectSLATarget, Label: "Ціль SLA"},
|
||||||
|
|
@ -402,9 +560,11 @@ func AuditBlindSpots() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"Вхід у систему, вихід і невдалі спроби входу. Спроби входу — і вдалі, і ні — лягають в окрему таблицю core.login_attempts, а не сюди: журнал аудиту вимагає tenant_id, а на момент перевірки пароля кабінет ще невідомий. Тобто цієї сторінки для питання «хто заходив» замало.",
|
"Вхід у систему, вихід і невдалі спроби входу. Спроби входу — і вдалі, і ні — лягають в окрему таблицю core.login_attempts, а не сюди: журнал аудиту вимагає tenant_id, а на момент перевірки пароля кабінет ще невідомий. Тобто цієї сторінки для питання «хто заходив» замало.",
|
||||||
"Поодинокі зміни хостів: створення, правка й видалення одного хоста в його картці. У журнал пишуть лише масові дії — а «видалив один хост» і «видалив сорок» відрізняються масштабом, не суттю. Виняток — повернення хоста з архіву й повне видалення: обидві йдуть масовим шляхом навіть для одного хоста, тож записуються завжди.",
|
"Поодинокі зміни хостів: створення, правка й видалення одного хоста в його картці. У журнал пишуть лише масові дії — а «видалив один хост» і «видалив сорок» відрізняються масштабом, не суттю. Виняток — повернення хоста з архіву й повне видалення: обидві йдуть масовим шляхом навіть для одного хоста, тож записуються завжди.",
|
||||||
"Склад команди: запрошення, зміна ролі людини, вилучення з кабінету. САМІ ролі — створення, зміна набору прав, видалення — з 0053 у журналі є (core.role.*), а от «кому цю роль видали» — ні. Тобто на питання «звідки в цієї людини такий доступ» журнал відповідає лише наполовину: що дозволяє роль, видно, хто в ній опинився — ні.",
|
"Видимість учасника: перелік груп, якими обмежено його доступ (memberships.scope_group_ids). Хто в кабінеті й з якою роллю, журнал бачить (core.member.*), а от звуження чи розширення видимості всередині ролі — ні; зараз його й нема чим змінити через API, тож рядок стоїть тут на випадок, коли така ручка з'явиться.",
|
||||||
"Доступи до обладнання: створення, правка й видалення облікових даних, а також те, кому їх призначили.",
|
"Доступи до обладнання: створення, правка й видалення облікових даних, а також те, кому їх призначили.",
|
||||||
"Мапи, групи, шаблони, правила алертів і канали сповіщень. Правка мапи оборотна й видима в самій мапі, але «хто вимкнув правило, за яким приходив алерт» звідси не видно.",
|
"Мапи, групи й шаблони. Правка тут оборотна й видима в самому об'єкті: мапа зберігає ревізії й уміє відкат, склад групи видно у групі, у шаблона є час останньої правки. Хто саме правив — не видно, і це свідома межа, а не пропуск. Правила алертів, канали сповіщень і драбини ескалації з цього рядка прибрано: вони в журналі є (alr.*), бо їхня правка не лишає по собі нічого — вимкнене правило виглядає точно так само, як правило, за яким просто нічого не сталось.",
|
||||||
|
"Тригери, породжені шаблоном. Шаблон описує правило один раз на клас пристроїв, і правлять його там; окремого сліду про кожне похідне правило журнал не пише — інакше одна правка шаблону давала б сотню однакових рядків, за якими не видно самої правки.",
|
||||||
|
"Пробне повідомлення в канал (кнопка «Перевірити»). Нічого не змінює й нічого не вмикає — це діагностика, а не дія над каналом.",
|
||||||
"Ручний запуск збору конфігу. Відкат конфігурації журнал бачить з 0060 — намір, погодження, відмову й зміну правил погодження, — а от «зібрати зараз» лишається поза ним: збір нічого не змінює на пристрої.",
|
"Ручний запуск збору конфігу. Відкат конфігурації журнал бачить з 0060 — намір, погодження, відмову й зміну правил погодження, — а от «зібрати зараз» лишається поза ним: збір нічого не змінює на пристрої.",
|
||||||
"Читання. Журнал записує зміни, а не перегляди: те, що хтось відкрив чужий конфіг або вивантажив архів, тут не з'явиться — окрім вивантаження звіту про прогін команд.",
|
"Читання. Журнал записує зміни, а не перегляди: те, що хтось відкрив чужий конфіг або вивантажив архів, тут не з'явиться — окрім вивантаження звіту про прогін команд.",
|
||||||
"Дії зондів і колектора: усе, що система робить сама за розкладом, журналом не покривається — це не дії людини, і їхнє місце в логах служб.",
|
"Дії зондів і колектора: усе, що система робить сама за розкладом, журналом не покривається — це не дії людини, і їхнє місце в логах служб.",
|
||||||
|
|
|
||||||
168
server/internal/store/audit_actions_zones_test.go
Normal file
168
server/internal/store/audit_actions_zones_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/token"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Дві закриті сліпі зони — перевірка з обох боків.
|
||||||
|
//
|
||||||
|
// ЧОМУ ЦЬОГО НЕ РОБИТЬ СУСІДНІЙ ТЕСТ
|
||||||
|
//
|
||||||
|
// audit_actions_source_test.go перевіряє напрямок «ключ пишеться в
|
||||||
|
// журнал → у нього є назва» і свідомо не перевіряє зворотного: журнал
|
||||||
|
// старший за код, і назви до дій збірок, яких уже немає, мають лишатись
|
||||||
|
// (див. «ЧОГО ТЕСТ НЕ ВИМАГАЄ» у його шапці).
|
||||||
|
//
|
||||||
|
// Але для дій, ДОДАНИХ ЗАРАЗ, зворотний напрямок — це і є вся робота.
|
||||||
|
// Рядок у словнику коштує нічого, і саме тому забути обробник поруч із
|
||||||
|
// ним нічого не коштує теж: сторінка аудиту покаже нову назву у фільтрі,
|
||||||
|
// фільтр відпрацює, а подій за ним не буде жодної — тобто вийде та сама
|
||||||
|
// порожнеча, що й до роботи, тільки тепер вона виглядає як «нічого не
|
||||||
|
// робили». Тому нижче перелічені ключі мусять не лише мати назву, а й
|
||||||
|
// справді писатись кодом із server/internal.
|
||||||
|
//
|
||||||
|
// Перелік навмисно записаний руками, а не виведений із auditActions за
|
||||||
|
// префіксом: він фіксує обіцянку конкретної роботи. Наступний, хто
|
||||||
|
// додасть дію в цій же зоні, або допише її сюди свідомо, або не
|
||||||
|
// зачепить цей тест узагалі — обидва варіанти чесні.
|
||||||
|
|
||||||
|
// closedZoneActions — дії, заради яких і робилась ця робота.
|
||||||
|
var closedZoneActions = map[string]string{
|
||||||
|
// Зона А: алертна частина.
|
||||||
|
AuditActionAlertRuleCreate: "Сповіщення",
|
||||||
|
AuditActionAlertRuleUpdate: "Сповіщення",
|
||||||
|
AuditActionAlertRuleDelete: "Сповіщення",
|
||||||
|
AuditActionAlertRuleEnable: "Сповіщення",
|
||||||
|
AuditActionAlertRuleDisable: "Сповіщення",
|
||||||
|
AuditActionChannelCreate: "Сповіщення",
|
||||||
|
AuditActionChannelUpdate: "Сповіщення",
|
||||||
|
AuditActionChannelDelete: "Сповіщення",
|
||||||
|
AuditActionEscalationCreate: "Сповіщення",
|
||||||
|
AuditActionEscalationUpdate: "Сповіщення",
|
||||||
|
AuditActionEscalationDelete: "Сповіщення",
|
||||||
|
// Правила відповідності — та сама зона: вимога, яку зняли, так само
|
||||||
|
// не лишає по собі нічого.
|
||||||
|
AuditActionComplianceRuleCreate: "Конфігурації",
|
||||||
|
AuditActionComplianceRuleUpdate: "Конфігурації",
|
||||||
|
AuditActionComplianceRuleDelete: "Конфігурації",
|
||||||
|
|
||||||
|
// Зона Б: склад команди.
|
||||||
|
AuditActionMemberAdd: "Адміністрування",
|
||||||
|
AuditActionMemberRole: "Адміністрування",
|
||||||
|
AuditActionMemberRemove: "Адміністрування",
|
||||||
|
AuditActionMemberProfile: "Адміністрування",
|
||||||
|
AuditActionMemberPassword: "Адміністрування",
|
||||||
|
}
|
||||||
|
|
||||||
|
// closedZoneObjects — типи об'єктів тих самих зон.
|
||||||
|
var closedZoneObjects = []string{
|
||||||
|
AuditObjectAlertRule,
|
||||||
|
AuditObjectChannel,
|
||||||
|
AuditObjectEscalation,
|
||||||
|
AuditObjectComplianceRule,
|
||||||
|
AuditObjectMember,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClosedZonesHaveLabels(t *testing.T) {
|
||||||
|
for key, group := range closedZoneActions {
|
||||||
|
info, ok := auditActionByKey[key]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("дія %q не має назви у словнику", key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Label == "" {
|
||||||
|
t.Errorf("дія %q має порожню назву", key)
|
||||||
|
}
|
||||||
|
if info.Group != group {
|
||||||
|
t.Errorf("дія %q у розділі %q, а очікувався %q", key, info.Group, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range closedZoneObjects {
|
||||||
|
if auditObjectTypeByKey[key] == "" {
|
||||||
|
t.Errorf("тип об'єкта %q не має назви у словнику", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Руйнівні дії позначені саме там, де після дії об'єкта більше немає.
|
||||||
|
//
|
||||||
|
// Позначка керує тим, як подію видно на сторінці, і помилка в ній тиха:
|
||||||
|
// видалення, яке не виглядає видаленням, губиться серед правок рівно
|
||||||
|
// тоді, коли його шукають.
|
||||||
|
func TestClosedZonesMarkDestructive(t *testing.T) {
|
||||||
|
gone := map[string]bool{
|
||||||
|
AuditActionAlertRuleDelete: true,
|
||||||
|
AuditActionChannelDelete: true,
|
||||||
|
AuditActionEscalationDelete: true,
|
||||||
|
AuditActionComplianceRuleDelete: true,
|
||||||
|
// Членства більше немає, сесії відкликані. Сам обліковий запис
|
||||||
|
// лишається — він може працювати в іншому кабінеті, — але
|
||||||
|
// доступу САМЕ сюди в людини вже нема.
|
||||||
|
AuditActionMemberRemove: true,
|
||||||
|
}
|
||||||
|
for key := range closedZoneActions {
|
||||||
|
if auditActionByKey[key].Destructive != gone[key] {
|
||||||
|
t.Errorf("дія %q: позначка «об'єкта більше немає» = %v, очікувалось %v",
|
||||||
|
key, auditActionByKey[key].Destructive, gone[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кожна нова дія має справді писатись кодом.
|
||||||
|
func TestClosedZonesAreActuallyWritten(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
files := parseAuditScanTree(t, fset)
|
||||||
|
consts := auditConstValues(files)
|
||||||
|
|
||||||
|
written := map[string]bool{}
|
||||||
|
for _, u := range collectAuditKeyUses(t, fset, files, consts) {
|
||||||
|
written[u.key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range closedZoneActions {
|
||||||
|
if !written[key] {
|
||||||
|
t.Errorf("дія %q є у словнику, але жодне місце коду її не пише — "+
|
||||||
|
"у фільтрі з'явиться назва, за якою ніколи не буде подій", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range closedZoneObjects {
|
||||||
|
if !written[key] {
|
||||||
|
t.Errorf("тип об'єкта %q є у словнику, але його ніхто не пише", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перелік сліпих зон правиться РАЗОМ зі словником — це вимога з шапки
|
||||||
|
// AuditBlindSpots, і без перевірки вона тримається лише на пам'яті.
|
||||||
|
func TestBlindSpotsDropClosedZones(t *testing.T) {
|
||||||
|
spots := AuditBlindSpots()
|
||||||
|
|
||||||
|
// Закрите звідти зникло. Шукаємо початок рядка, а не будь-яку
|
||||||
|
// згадку: рядок про мапи навмисно ЗГАДУЄ правила алертів, щоб
|
||||||
|
// сказати, що їх звідти прибрано, і збіг за словом «правила» був би
|
||||||
|
// хибним спрацюванням.
|
||||||
|
for _, gone := range []string{"Склад команди:", "Мапи, групи, шаблони, правила алертів"} {
|
||||||
|
for _, s := range spots {
|
||||||
|
if strings.HasPrefix(s, gone) {
|
||||||
|
t.Errorf("зона закрита, а рядок про неї лишився: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свідомо лишене — лишилось, і з причиною. Мапи, групи й шаблони
|
||||||
|
// прибирати з переліку не можна: їх ця робота не покривала.
|
||||||
|
found := false
|
||||||
|
for _, s := range spots {
|
||||||
|
if strings.HasPrefix(s, "Мапи, групи й шаблони") {
|
||||||
|
found = true
|
||||||
|
if !strings.Contains(s, "видима в самому об'єкті") {
|
||||||
|
t.Errorf("рядок про мапи лишився без причини: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("з переліку зникли мапи, групи й шаблони — а вони в журнал не пишуться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -659,7 +659,12 @@ func auditSummary(meta []byte) string {
|
||||||
// вони витісняють імена, а імена й є те, що людина впізнає. Самі
|
// вони витісняють імена, а імена й є те, що людина впізнає. Самі
|
||||||
// ідентифікатори нікуди не діваються — вони у повному вмісті, який
|
// ідентифікатори нікуди не діваються — вони у повному вмісті, який
|
||||||
// приїздить по натисканню.
|
// приїздить по натисканню.
|
||||||
|
// name і username стоять першими: у записах про правило, канал,
|
||||||
|
// драбину чи учасника це єдине, що дає впізнати об'єкт з переліку.
|
||||||
|
// Решта полів там — подробиці правки, і читають їх уже після того,
|
||||||
|
// як знайшли потрібний рядок.
|
||||||
preferred := []string{
|
preferred := []string{
|
||||||
|
"name", "username",
|
||||||
"devices", "deleted", "updated", "stopped", "names", "commands",
|
"devices", "deleted", "updated", "stopped", "names", "commands",
|
||||||
"counts", "status", "versions", "format", "enabled", "freed_bytes",
|
"counts", "status", "versions", "format", "enabled", "freed_bytes",
|
||||||
"created_by", "filter",
|
"created_by", "filter",
|
||||||
|
|
@ -728,6 +733,22 @@ var auditKeyLabels = map[string]string{
|
||||||
"failed": "невдало",
|
"failed": "невдало",
|
||||||
"canceled": "скасовано",
|
"canceled": "скасовано",
|
||||||
"pending": "у черзі",
|
"pending": "у черзі",
|
||||||
|
|
||||||
|
// Алертна частина й склад команди.
|
||||||
|
"name": "назва",
|
||||||
|
"username": "логін",
|
||||||
|
"kind": "вид",
|
||||||
|
"severity": "серйозність",
|
||||||
|
"source": "джерело",
|
||||||
|
"pattern": "зразок",
|
||||||
|
"fields": "поля",
|
||||||
|
"role_key": "роль",
|
||||||
|
"role_name": "роль",
|
||||||
|
"rule_count": "правил",
|
||||||
|
"steps": "сходинки",
|
||||||
|
// «Секрет змінено», а не сам секрет: токена каналу в meta немає
|
||||||
|
// (див. channelAuditMeta), і назва має читатись саме як факт заміни.
|
||||||
|
"secret_changed": "секрет змінено",
|
||||||
}
|
}
|
||||||
|
|
||||||
func auditKeyLabel(k string) string {
|
func auditKeyLabel(k string) string {
|
||||||
|
|
|
||||||
|
|
@ -509,14 +509,27 @@ func (s *Store) SaveComplianceRule(ctx context.Context, tenantID string, r Compl
|
||||||
// лише те, чого в кабінеті НЕМАЄ. Видалене вбудоване правило повернулось
|
// лише те, чого в кабінеті НЕМАЄ. Видалене вбудоване правило повернулось
|
||||||
// би при наступному накаті довідника — уже ввімкненим і без сліду, що
|
// би при наступному накаті довідника — уже ввімкненим і без сліду, що
|
||||||
// його свідомо прибирали.
|
// його свідомо прибирали.
|
||||||
func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) error {
|
// ComplianceRuleRef — те, що видалення розповідає про правило журналу
|
||||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
// аудиту. Читається до DELETE: після нього самої вимоги немає ніде, і
|
||||||
|
// «правило 7f3a…» у журналі не відповідає на питання, чого саме
|
||||||
|
// перестали перевіряти.
|
||||||
|
type ComplianceRuleRef struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Severity string
|
||||||
|
Pattern string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) (ComplianceRuleRef, error) {
|
||||||
|
ref := ComplianceRuleRef{ID: id}
|
||||||
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
var builtin string
|
var builtin string
|
||||||
err := tx.QueryRow(ctx, `
|
err := tx.QueryRow(ctx, `
|
||||||
SELECT COALESCE(builtin_key,'') FROM ncm.compliance_rules
|
SELECT COALESCE(builtin_key,''), name, severity::text, pattern
|
||||||
|
FROM ncm.compliance_rules
|
||||||
WHERE id = $1 AND tenant_id = $2
|
WHERE id = $1 AND tenant_id = $2
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`, id, tenantID).Scan(&builtin)
|
`, id, tenantID).Scan(&builtin, &ref.Name, &ref.Severity, &ref.Pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isNoRows(err) {
|
if isNoRows(err) {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
|
|
@ -538,6 +551,7 @@ func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) e
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return ref, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListComplianceResults — знахідки, спершу провалені й найважчі.
|
// ListComplianceResults — знахідки, спершу провалені й найважчі.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue