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

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

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

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

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

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

270 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

package httpapi
import (
"encoding/json"
"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}
}