За цілий день активної роботи в журналі не з'явилось нічого: цих доменів у ньому просто не було. Проєкт це визнавав сам, у AuditBlindSpots(). Тепер пишуться: правила алертів (створення/зміна/видалення й окремо вимкнення-увімкнення), канали, драбини ескалації, правила відповідності; додавання людини в кабінет, зміна ролі, вилучення, правка профілю й скидання пароля. Два рішення про зміст запису: * вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці подробиць — питання «хто вимкнув правило, за яким приходив алерт» має відповідатись переліком, а не порівнянням; * config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й адреса вебхука (доступ на запис у чужий чат) і заголовок Authorization. Замість нього прапорець secret_changed. Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери від шаблонів пишуться повз обробник, і пробне повідомлення в канал. Прогнано проти справжньої бази: секрет каналу й пароль учасника в подробиці не потрапляють. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
14 KiB
Go
270 lines
14 KiB
Go
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}
|
||
}
|