Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди

За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-29 01:06:21 +03:00
parent 6de46550ea
commit f6df538020
16 changed files with 1176 additions and 60 deletions

View file

@ -338,7 +338,8 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
// розділяти її на дві гілки на клієнті означало б розійтися в
// перевірках рівно там, де вони найпотрібніші.
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) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
@ -360,6 +361,22 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// Дію обирає перехід прапорця, а не те, що це PUT: збереження
// форми, у якому серед іншого зняли галочку «Увімкнено», — це
// вимкнення правила, і в журналі воно має так і називатись.
//
// Три виклики замість одного з обчисленим ключем — вимога
// перевірки словника (audit_actions_source_test.go): ключ, який
// приїхав змінною, нічим не звірити з переліком назв.
meta := alertRuleAuditMeta(in, ref.Enabled)
switch ruleSaveOutcome(ref) {
case ruleSavedAsDisable:
s.audit(r, p, store.AuditActionAlertRuleDisable, store.AuditObjectAlertRule, id, meta)
case ruleSavedAsEnable:
s.audit(r, p, store.AuditActionAlertRuleEnable, store.AuditObjectAlertRule, id, meta)
default:
s.audit(r, p, store.AuditActionAlertRuleUpdate, store.AuditObjectAlertRule, id, meta)
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
return
}
@ -378,6 +395,8 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionAlertRuleCreate, store.AuditObjectAlertRule, id,
alertRuleAuditMeta(in, in.Enabled == nil || *in.Enabled))
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
@ -393,7 +412,7 @@ func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p
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) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
@ -407,6 +426,18 @@ func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// Дію беремо з того, ЩО ПРОСИЛИ, а не з переходу (як у PUT вище):
// цей запит уміє рівно одне, і «вимкнути вже вимкнене» — теж
// вимкнення, тобто натиснута кнопка, а не порожня правка. Чи був
// перехід насправді, видно з was_enabled.
meta := map[string]any{
"name": ref.Name, "enabled": ref.Enabled, "was_enabled": ref.WasEnabled,
}
if *req.Enabled {
s.audit(r, p, store.AuditActionAlertRuleEnable, store.AuditObjectAlertRule, ref.ID, meta)
} else {
s.audit(r, p, store.AuditActionAlertRuleDisable, store.AuditObjectAlertRule, ref.ID, meta)
}
w.WriteHeader(http.StatusNoContent)
}
@ -414,7 +445,7 @@ func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p
if !requirePerm(w, p, "alerts:write") {
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) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
@ -428,6 +459,11 @@ func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// enabled — стан на момент видалення. Різниця відчутна: видалити
// правило, яке щойно будило зміну, і видалити те, що рік стояло
// вимкненим, — це різні події з однаковою назвою.
s.audit(r, p, store.AuditActionAlertRuleDelete, store.AuditObjectAlertRule, ref.ID,
map[string]any{"name": ref.Name, "enabled": ref.WasEnabled})
w.WriteHeader(http.StatusNoContent)
}
@ -553,6 +589,8 @@ func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionChannelUpdate, store.AuditObjectChannel, id,
channelAuditMeta(in))
writeJSON(w, http.StatusOK, map[string]any{"id": id})
return
}
@ -567,6 +605,8 @@ func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionChannelCreate, store.AuditObjectChannel, id,
channelAuditMeta(in))
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
@ -574,7 +614,7 @@ func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *
if !requirePerm(w, p, "alerts:write") {
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) {
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
return
@ -588,6 +628,12 @@ func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
// Видалення каналу тихо переписує чужі об'єкти: посилання на нього
// зникають зі сходинок драбин, правил і маршрутів (див.
// DeleteChannel). Тому запис потрібен саме тут — у самих правилах
// сліду не лишиться.
s.audit(r, p, store.AuditActionChannelDelete, store.AuditObjectChannel, ref.ID,
map[string]any{"kind": ref.Kind, "name": ref.Name})
w.WriteHeader(http.StatusNoContent)
}
@ -717,16 +763,20 @@ func (s *Server) handleSaveEscalationPolicy(w http.ResponseWriter, r *http.Reque
return
}
id, err := s.store.SaveEscalationPolicy(r.Context(), p.TenantID, r.PathValue("id"),
store.EscalationPolicy{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
Steps: req.Steps,
RepeatAfterMin: req.RepeatAfterMin,
MaxRepeats: req.MaxRepeats,
pol := store.EscalationPolicy{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
Steps: req.Steps,
RepeatAfterMin: req.RepeatAfterMin,
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 errors.Is(err, store.ErrNotFound) {
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", "внутрішня помилка")
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})
}
@ -760,7 +817,7 @@ func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Req
if !requirePerm(w, p, "alerts:write") {
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) {
writeError(w, http.StatusNotFound, "not_found", "політику не знайдено")
return
@ -774,5 +831,7 @@ func (s *Server) handleDeleteEscalationPolicy(w http.ResponseWriter, r *http.Req
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
s.audit(r, p, store.AuditActionEscalationDelete, store.AuditObjectEscalation, ref.ID,
map[string]any{"name": ref.Name, "rule_count": ref.RuleCount})
w.WriteHeader(http.StatusNoContent)
}

View 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}
}

View 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 має лягти рядком, а не завалити весь запис")
}
}

View file

@ -340,7 +340,7 @@ func TestRevokedMembershipBlocksToken(t *testing.T) {
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)
}

View file

@ -96,11 +96,23 @@ func (s *Server) handleSaveComplianceRule(w http.ResponseWriter, r *http.Request
in.ConfigType = "running"
}
// Створення чи зміна — видно лише зі шляху: збереження повертає
// однаковий id в обох випадках, і після нього відрізнити їх уже
// нічим.
existing := in.ID != ""
id, err := s.store.SaveComplianceRule(r.Context(), p.TenantID, in)
if err != nil {
s.writeStoreError(w, "правило відповідності", err)
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})
}
@ -108,10 +120,16 @@ func (s *Server) handleDeleteComplianceRule(w http.ResponseWriter, r *http.Reque
if !requirePerm(w, p, "ncm:write") {
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)
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)
}

View file

@ -94,6 +94,12 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
s.writeStoreError(w, "створення користувача", err)
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)
}
@ -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 != "" {
err := s.store.UpdateUserProfile(r.Context(), p.TenantID, userID, store.UserProfileInput{
profile := store.UserProfileInput{
Username: in.Username,
Email: in.Email,
FullName: in.FullName,
Password: in.Password,
})
}
username, err := s.store.UpdateUserProfile(r.Context(), p.TenantID, userID, profile)
if errors.Is(err, store.ErrSharedUser) {
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)
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 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) {
writeError(w, http.StatusNotFound, "not_found", "учасника або роль не знайдено")
return
@ -176,6 +193,21 @@ func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Princi
s.writeStoreError(w, "зміна ролі", err)
return
}
// Роль, яка не змінилась, у журнал не йде. Форма команди шле
// role_id завжди — навіть коли правили лише ім'я, — і рядок
// «змінив роль з Адміністратора на Адміністратора» був би не
// подією, а шумом рівно в тому переліку, який відкривають, щоб
// подію знайти.
//
// Окремий запис, а не поле в правці профілю вище, хоч форма й
// одна: це різні події для різних читачів. Профіль питають про
// «хто перейменував», роль — про «звідки в цієї людини такий
// доступ», і зводити їх в один рядок означало б, що друге
// питання доводиться шукати всередині відповіді на перше.
if before.RoleID != after.RoleID {
s.audit(r, p, store.AuditActionMemberRole, store.AuditObjectMember, userID,
memberRoleAuditMeta(before, after))
}
}
w.WriteHeader(http.StatusNoContent)
@ -192,7 +224,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, p *P
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) {
writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено")
return
@ -201,5 +233,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, p *P
s.writeStoreError(w, "видалення учасника", err)
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)
}

View file

@ -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)
}
ps, err := st.ListEscalationPolicies(ctx, ours)

View file

@ -433,8 +433,34 @@ func ladderChannelIDs(steps []EscalationStep) []string {
return out
}
func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// ChannelRef — те, що дія запису розповідає про канал журналу аудиту.
//
// Ні секрету, ні 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 — це чинний доступ, про який
// уже ніхто не пам'ятає.
@ -529,6 +555,7 @@ func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) e
}
return nil
})
return ref, err
}
// RuleAction — куди й коли шле саме це правило.

View file

@ -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 {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// EscalationRef — те, що дія запису розповідає про драбину журналу
// аудиту.
//
// 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,
`DELETE FROM alr.escalation_policies WHERE tenant_id = $1 AND id = $2`, tenantID, id)
if err != nil {
@ -277,6 +308,7 @@ func (s *Store) DeleteEscalationPolicy(ctx context.Context, tenantID, id string)
}
return nil
})
return ref, err
}
// ---------------------------------------------------------------------

View file

@ -510,8 +510,26 @@ func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in Rule
return id, err
}
func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// RuleRef — те, що дія запису розповідає про правило журналу аудиту.
//
// Повертається з самої дії, а не читається окремим запитом поруч, з
// двох причин. Після 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,
@ -521,29 +539,41 @@ func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) error {
if err := resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило видалено"); err != nil {
return err
}
tag, err := tx.Exec(ctx,
`DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2`, tenantID, ruleID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
err := tx.QueryRow(ctx, `
DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2
RETURNING id::text, name, enabled
`, tenantID, ruleID).Scan(&ref.ID, &ref.Name, &ref.WasEnabled)
if isNoRows(err) {
return ErrAlertNotFound
}
return nil
return err
})
return ref, err
}
func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE alr.rules SET enabled = $3, updated_at = now()
func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) (RuleRef, error) {
ref := RuleRef{ID: ruleID, Enabled: enabled}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Читання під замком у тій же транзакції, що й запис, — з тієї
// ж причини, що в UpdateRule нижче: між читанням і UPDATE
// уміщається збереження форми правила, і без FOR UPDATE журнал
// назвав би дією те, чого не сталось.
err := tx.QueryRow(ctx, `
SELECT name, enabled FROM alr.rules
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 {
return err
}
if tag.RowsAffected() == 0 {
return ErrAlertNotFound
if _, err := tx.Exec(ctx, `
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 ref, err
}
// 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 {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) (RuleRef, error) {
ref := RuleRef{ID: ruleID, Name: in.Name}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Стан ДО правки читається окремо й під замком.
//
// Окремо — бо після UPDATE його вже не відновити, а рішення про
@ -651,6 +683,7 @@ func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in Rule
return err
}
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 ref, err
}

View file

@ -145,7 +145,7 @@ func TestRuleChannelsAgainstDB(t *testing.T) {
// Окремий випадок навмисно: перевірка лише на створенні лишала б
// двері відчиненими — правило заводять справним, а ламають правкою.
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("правка підставила канал чужого кабінету")
} else if !errors.Is(err, ErrInvalid) {
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) {
t.Fatalf("правка неіснуючого правила має давати ErrNotFound: %v", err)
}
@ -184,7 +184,7 @@ func TestRuleChannelsAgainstDB(t *testing.T) {
//
// Симетрично сходинкам драбини: залишений UUID видаленого каналу —
// це правило, яке виглядає налаштованим і не шле нікуди.
if err := st.DeleteChannel(ctx, ours, duty); err != nil {
if _, err := st.DeleteChannel(ctx, ours, duty); err != nil {
t.Fatalf("видалення каналу: %v", err)
}
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)
}
if got := ruleChannels(ours, ruleID); len(got) != 0 {

View file

@ -131,7 +131,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
in := base(slug + "-перше")
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)
}
if !ruleEnabled(r1) {
@ -142,7 +142,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
}
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)
}
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)
}
if err := st.pool.QueryRow(ctx, `
@ -191,7 +191,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
in2 := base(slug + "-друге")
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)
}
if ruleEnabled(r2) {
@ -201,7 +201,7 @@ func TestUpdateRuleAgainstDB(t *testing.T) {
// І другий бік тієї ж пари: явне `true` мусить вмикати.
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)
}
if !ruleEnabled(r2) {

View file

@ -63,10 +63,34 @@ const (
AuditActionMirrorUpdate = "ncm.mirror.update"
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"
AuditActionRoleUpdate = "core.role.update"
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"
AuditActionTelegramUnlink = "core.telegram.unlink"
@ -90,7 +114,12 @@ const (
AuditObjectConfig = "ncm.config"
AuditObjectBackupDefaults = "ncm.backup_defaults"
AuditObjectMirror = "ncm.mirror"
AuditObjectComplianceRule = "ncm.compliance_rule"
AuditObjectAlertRule = "alr.rule"
AuditObjectChannel = "alr.channel"
AuditObjectEscalation = "alr.escalation_policy"
AuditObjectRole = "core.role"
AuditObjectMember = "core.member"
AuditObjectTelegram = "core.telegram_account"
AuditObjectRetention = "core.retention_settings"
AuditObjectSLATarget = "core.sla_target"
@ -113,8 +142,10 @@ type AuditActionInfo struct {
//
// Перелік і є вся правда про покриття: аудит пишуть масові дії над
// хостами, виконання команд, руйнівні дії над архівом конфігів,
// налаштування дзеркала Git і зміни складу ролей. Решта продукту в
// журнал не пише нічого — див. AuditBlindSpots.
// налаштування дзеркала Git, правила відповідності, вся алертна
// частина (правила, канали, драбини ескалації), зміни складу ролей і
// склад команди. Решта продукту в журнал не пише нічого — див.
// AuditBlindSpots.
var auditActions = []AuditActionInfo{
{
Key: AuditActionDeviceBulkUpdate, Group: "Інвентар",
@ -213,6 +244,90 @@ var auditActions = []AuditActionInfo{
Key: AuditActionMirrorPush, Group: "Конфігурації",
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: "Адміністрування",
Label: "Створення ролі",
@ -225,6 +340,44 @@ var auditActions = []AuditActionInfo{
Key: AuditActionRoleDelete, Group: "Адміністрування",
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: "Адміністрування",
// Прив'язка не видає нових прав, але дає новий СПОСІБ ними
@ -325,7 +478,12 @@ var auditObjectTypes = []AuditActionInfo{
{Key: AuditObjectConfig, Label: "Версія конфігу"},
{Key: AuditObjectBackupDefaults, Label: "Налаштування бекапів"},
{Key: AuditObjectMirror, Label: "Дзеркало Git"},
{Key: AuditObjectComplianceRule, Label: "Правило відповідності"},
{Key: AuditObjectAlertRule, Label: "Правило алертів"},
{Key: AuditObjectChannel, Label: "Канал сповіщень"},
{Key: AuditObjectEscalation, Label: "Драбина ескалації"},
{Key: AuditObjectRole, Label: "Роль"},
{Key: AuditObjectMember, Label: "Учасник кабінету"},
{Key: AuditObjectTelegram, Label: "Прив'язка Telegram"},
{Key: AuditObjectRetention, Label: "Строки зберігання даних"},
{Key: AuditObjectSLATarget, Label: "Ціль SLA"},
@ -402,9 +560,11 @@ func AuditBlindSpots() []string {
return []string{
"Вхід у систему, вихід і невдалі спроби входу. Спроби входу — і вдалі, і ні — лягають в окрему таблицю core.login_attempts, а не сюди: журнал аудиту вимагає tenant_id, а на момент перевірки пароля кабінет ще невідомий. Тобто цієї сторінки для питання «хто заходив» замало.",
"Поодинокі зміни хостів: створення, правка й видалення одного хоста в його картці. У журнал пишуть лише масові дії — а «видалив один хост» і «видалив сорок» відрізняються масштабом, не суттю. Виняток — повернення хоста з архіву й повне видалення: обидві йдуть масовим шляхом навіть для одного хоста, тож записуються завжди.",
"Склад команди: запрошення, зміна ролі людини, вилучення з кабінету. САМІ ролі — створення, зміна набору прав, видалення — з 0053 у журналі є (core.role.*), а от «кому цю роль видали» — ні. Тобто на питання «звідки в цієї людини такий доступ» журнал відповідає лише наполовину: що дозволяє роль, видно, хто в ній опинився — ні.",
"Видимість учасника: перелік груп, якими обмежено його доступ (memberships.scope_group_ids). Хто в кабінеті й з якою роллю, журнал бачить (core.member.*), а от звуження чи розширення видимості всередині ролі — ні; зараз його й нема чим змінити через API, тож рядок стоїть тут на випадок, коли така ручка з'явиться.",
"Доступи до обладнання: створення, правка й видалення облікових даних, а також те, кому їх призначили.",
"Мапи, групи, шаблони, правила алертів і канали сповіщень. Правка мапи оборотна й видима в самій мапі, але «хто вимкнув правило, за яким приходив алерт» звідси не видно.",
"Мапи, групи й шаблони. Правка тут оборотна й видима в самому об'єкті: мапа зберігає ревізії й уміє відкат, склад групи видно у групі, у шаблона є час останньої правки. Хто саме правив — не видно, і це свідома межа, а не пропуск. Правила алертів, канали сповіщень і драбини ескалації з цього рядка прибрано: вони в журналі є (alr.*), бо їхня правка не лишає по собі нічого — вимкнене правило виглядає точно так само, як правило, за яким просто нічого не сталось.",
"Тригери, породжені шаблоном. Шаблон описує правило один раз на клас пристроїв, і правлять його там; окремого сліду про кожне похідне правило журнал не пише — інакше одна правка шаблону давала б сотню однакових рядків, за якими не видно самої правки.",
"Пробне повідомлення в канал (кнопка «Перевірити»). Нічого не змінює й нічого не вмикає — це діагностика, а не дія над каналом.",
"Ручний запуск збору конфігу. Відкат конфігурації журнал бачить з 0060 — намір, погодження, відмову й зміну правил погодження, — а от «зібрати зараз» лишається поза ним: збір нічого не змінює на пристрої.",
"Читання. Журнал записує зміни, а не перегляди: те, що хтось відкрив чужий конфіг або вивантажив архів, тут не з'явиться — окрім вивантаження звіту про прогін команд.",
"Дії зондів і колектора: усе, що система робить сама за розкладом, журналом не покривається — це не дії людини, і їхнє місце в логах служб.",

View 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("з переліку зникли мапи, групи й шаблони — а вони в журнал не пишуться")
}
}

View file

@ -659,7 +659,12 @@ func auditSummary(meta []byte) string {
// вони витісняють імена, а імена й є те, що людина впізнає. Самі
// ідентифікатори нікуди не діваються — вони у повному вмісті, який
// приїздить по натисканню.
// name і username стоять першими: у записах про правило, канал,
// драбину чи учасника це єдине, що дає впізнати об'єкт з переліку.
// Решта полів там — подробиці правки, і читають їх уже після того,
// як знайшли потрібний рядок.
preferred := []string{
"name", "username",
"devices", "deleted", "updated", "stopped", "names", "commands",
"counts", "status", "versions", "format", "enabled", "freed_bytes",
"created_by", "filter",
@ -728,6 +733,22 @@ var auditKeyLabels = map[string]string{
"failed": "невдало",
"canceled": "скасовано",
"pending": "у черзі",
// Алертна частина й склад команди.
"name": "назва",
"username": "логін",
"kind": "вид",
"severity": "серйозність",
"source": "джерело",
"pattern": "зразок",
"fields": "поля",
"role_key": "роль",
"role_name": "роль",
"rule_count": "правил",
"steps": "сходинки",
// «Секрет змінено», а не сам секрет: токена каналу в meta немає
// (див. channelAuditMeta), і назва має читатись саме як факт заміни.
"secret_changed": "секрет змінено",
}
func auditKeyLabel(k string) string {

View file

@ -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 {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// ComplianceRuleRef — те, що видалення розповідає про правило журналу
// аудиту. Читається до 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
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
FOR UPDATE
`, id, tenantID).Scan(&builtin)
`, id, tenantID).Scan(&builtin, &ref.Name, &ref.Severity, &ref.Pattern)
if err != nil {
if isNoRows(err) {
return ErrNotFound
@ -538,6 +551,7 @@ func (s *Store) DeleteComplianceRule(ctx context.Context, tenantID, id string) e
}
return nil
})
return ref, err
}
// ListComplianceResults — знахідки, спершу провалені й найважчі.