Netpulse_SasS/server/internal/store/audit_actions.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

412 lines
25 KiB
Go
Raw 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 store
import "strings"
// Словник дій журналу аудиту.
//
// Навіщо він узагалі. Ключ `ncm.command_run.delete` читається лише тим,
// хто його писав; людина, яка шукає, хто стер результати прогону,
// шукає «видалення». Але сам ключ прибирати не можна: за ним фільтрують,
// його шлють у підтримку, за ним шукають у логах сервера. Тому в
// інтерфейсі є обидва — фраза великим, ключ поруч дрібним.
//
// Словник живе тут, а не в браузері, з однієї причини: перелік дій — це
// властивість того, що продукт ЗАПИСУЄ, а не того, як це показано.
// Наступний, хто додасть виклик WriteAudit, дописує рядок сюди, поруч
// із рештою, — і сторінка, вивантаження й будь-що майбутнє отримують
// однакову назву. Копія словника в TypeScript розійшлася б із цією на
// першому ж новому рядку, і розбіжність було б видно лише тому, хто
// відкриє обидва файли поруч.
//
// Ключа, якого тут немає, це не ламає: подія показується самим лише
// ключем. Журнал старший за словник — у ньому лежать дії збірок, яких
// уже немає, і мовчки ховати їх було б гірше, ніж показати як є.
//
// ЧОМУ КЛЮЧІ — КОНСТАНТИ, А НЕ РЯДКИ ПО МІСЦЯХ ВИКЛИКУ
//
// Прохання «не забудь дописати рядок сюди» цей файл уже програв: за
// півроку в журнал почали писати дзеркало Git, ролі й прив'язку хоста
// до зонда — жодна з цих дій до словника не потрапила, і адміністратор
// бачив у переліку сирі ключі. Помітити це неможливо ніяк, крім як
// відкрити журнал і впізнати відсутню назву.
//
// Тому ключ тепер має рівно одне місце оголошення — константу нижче, —
// а поруч стоїть тест (audit_actions_source_test.go), який читає ВЕСЬ
// server/internal, знаходить кожне присвоєння AuditEntry.Action і падає
// на двох речах: ключ не оголошено константою тут або в константи немає
// назви у словнику. Тобто наступна нова дія не має способу потрапити в
// журнал безіменною: збірка не пройде.
// Ключі дій. Значення — те, що лягає в core.audit_log.action; змінювати
// їх заднім числом не можна, бо в журналі вже лежать рядки зі старим
// значенням, а журнал не переписують.
const (
AuditActionDeviceBulkUpdate = "inv.device.bulk_update"
AuditActionDeviceBulkDelete = "inv.device.bulk_delete"
AuditActionDeviceBulkPurge = "inv.device.bulk_purge"
AuditActionDeviceBulkRestore = "inv.device.bulk_restore"
AuditActionDeviceSelfAgent = "inv.device.self_agent"
AuditActionDeviceSelfAgentUn = "inv.device.self_agent_clear"
AuditActionCommandRunCreate = "ncm.command_run.create"
AuditActionCommandRunCancel = "ncm.command_run.cancel"
AuditActionCommandRunDelete = "ncm.command_run.delete"
AuditActionCommandRunReport = "ncm.command_run.report"
AuditActionRollbackCreate = "ncm.rollback.create"
AuditActionRollbackApprove = "ncm.rollback.approve"
AuditActionRollbackReject = "ncm.rollback.reject"
AuditActionRollbackPolicy = "ncm.rollback_policy.update"
AuditActionConfigDelete = "ncm.config.delete"
AuditActionRetentionUpdate = "ncm.retention.update"
AuditActionMirrorUpdate = "ncm.mirror.update"
AuditActionMirrorPush = "ncm.mirror.push"
AuditActionRoleCreate = "core.role.create"
AuditActionRoleUpdate = "core.role.update"
AuditActionRoleDelete = "core.role.delete"
AuditActionTelegramLink = "core.telegram.link"
AuditActionTelegramUnlink = "core.telegram.unlink"
AuditActionRetentionSettings = "core.retention.update"
AuditActionSLATargetSave = "core.sla_target.save"
AuditActionSLATargetDelete = "core.sla_target.delete"
AuditActionSLAPeriodClose = "core.sla_period.close"
AuditActionBillingPlan = "bill.plan.update"
AuditActionLicenseApply = "bill.license.apply"
AuditActionLicenseClear = "bill.license.clear"
)
// Ключі типів об'єктів — те, НАД ЧИМ зроблено дію.
const (
AuditObjectDevice = "inv.device"
AuditObjectCommandRun = "ncm.command_run"
AuditObjectRollback = "ncm.rollback"
AuditObjectRollbackPolicy = "ncm.rollback_policy"
AuditObjectConfig = "ncm.config"
AuditObjectBackupDefaults = "ncm.backup_defaults"
AuditObjectMirror = "ncm.mirror"
AuditObjectRole = "core.role"
AuditObjectTelegram = "core.telegram_account"
AuditObjectRetention = "core.retention_settings"
AuditObjectSLATarget = "core.sla_target"
AuditObjectBillingPlan = "bill.plan"
AuditObjectLicense = "bill.license"
)
// AuditActionInfo — те, що словник знає про дію.
type AuditActionInfo struct {
Key string `json:"key"`
Label string `json:"label"`
Group string `json:"group"`
// Destructive — після цієї дії об'єкта більше немає. Не «важлива»
// й не «небезпечна»: важливість суб'єктивна, а «щось зникло» —
// факт, і саме за ним журнал переглядають найчастіше.
Destructive bool `json:"destructive,omitempty"`
}
// auditActions — усе, що продукт уміє записувати СЬОГОДНІ.
//
// Перелік і є вся правда про покриття: аудит пишуть масові дії над
// хостами, виконання команд, руйнівні дії над архівом конфігів,
// налаштування дзеркала Git і зміни складу ролей. Решта продукту в
// журнал не пише нічого — див. AuditBlindSpots.
var auditActions = []AuditActionInfo{
{
Key: AuditActionDeviceBulkUpdate, Group: "Інвентар",
Label: "Масова правка хостів",
},
{
Key: AuditActionDeviceBulkDelete, Group: "Інвентар",
// Назва навмисно широка. Досі під цим ключем писались ОБИДВА
// режими видалення — і архівний, і повний (режим лежав у
// meta.mode), тож у журналі за ним стоять і ті, й ті рядки.
// Звузити назву до «архівування» означало б перейменувати
// заднім числом чужі події, яких ніхто вже не перевірить.
Label: "Масове видалення хостів", Destructive: true,
},
{
Key: AuditActionDeviceBulkPurge, Group: "Інвентар",
Label: "Повне видалення хостів разом з історією", Destructive: true,
},
{
Key: AuditActionDeviceBulkRestore, Group: "Інвентар",
Label: "Відновлення хостів з архіву",
},
{
Key: AuditActionDeviceSelfAgent, Group: "Інвентар",
Label: "Прив'язка хоста до машини зонда",
},
{
Key: AuditActionDeviceSelfAgentUn, Group: "Інвентар",
Label: "Зняття прив'язки хоста до машини зонда",
},
{
Key: AuditActionCommandRunCreate, Group: "Команди",
Label: "Запуск команд на обладнанні",
},
{
Key: AuditActionCommandRunCancel, Group: "Команди",
Label: "Зупинка прогону команд",
},
{
Key: AuditActionCommandRunDelete, Group: "Команди",
Label: "Видалення прогону разом із виводом", Destructive: true,
},
{
Key: AuditActionCommandRunReport, Group: "Команди",
Label: "Вивантаження звіту про прогін",
},
{
Key: AuditActionRollbackCreate, Group: "Конфігурації",
// Створення наміру, а не сама заливка. Різниця важлива: намір
// може так і не поїхати на пристрій — його відхилять або він
// застаріє. Те, що на залізо справді писали, видно в самому
// відкаті (стани applying → verifying → applied), і дублювати
// це рядком у журналі означало б мати два джерела правди про
// подію, яку найгірше знати неточно.
Label: "Намір відкотити конфіг на пристрої",
},
{
Key: AuditActionRollbackApprove, Group: "Конфігурації",
// Найважливіший рядок розділу. Відкат за політикою погоджує
// ДРУГА людина, і питання «хто дозволив залити старий конфіг на
// магістральний вузол» має мати відповідь з іменем і часом —
// незалежно від того, чи вцілів сам намір у базі.
Label: "Погодження відкату конфігу",
},
{
Key: AuditActionRollbackReject, Group: "Конфігурації",
Label: "Відмова у відкаті конфігу",
},
{
Key: AuditActionRollbackPolicy, Group: "Конфігурації",
// Зміна політики нічого не ламає в мить збереження — вона
// змінює правила для всього, що станеться далі. Саме тому
// рядок тут: «вимогу другої людини вимкнули за годину до
// аварії» інакше не з'ясувати ніяк.
Label: "Зміна правил погодження відкату",
},
{
Key: AuditActionConfigDelete, Group: "Конфігурації",
Label: "Видалення збережених версій конфігів", Destructive: true,
},
{
Key: AuditActionRetentionUpdate, Group: "Конфігурації",
Label: "Зміна політики очистки конфігів",
},
{
Key: AuditActionMirrorUpdate, Group: "Конфігурації",
// Одна назва на три дії, і це не спрощення: налаштування
// дзеркала, видача ключа розгортання й вимкнення дзеркала
// міняють той самий об'єкт і той самий рядок налаштувань.
// Що саме змінилось, видно в meta (url, auth, deploy_key), а
// три окремі рядки у фільтрі означали б три способи спитати
// одне питання.
Label: "Налаштування дзеркала Git",
},
{
Key: AuditActionMirrorPush, Group: "Конфігурації",
Label: "Примусовий пуш архіву на дзеркало",
},
{
Key: AuditActionRoleCreate, Group: "Адміністрування",
Label: "Створення ролі",
},
{
Key: AuditActionRoleUpdate, Group: "Адміністрування",
Label: "Зміна прав ролі",
},
{
Key: AuditActionRoleDelete, Group: "Адміністрування",
Label: "Видалення ролі", Destructive: true,
},
{
Key: AuditActionTelegramLink, Group: "Адміністрування",
// Прив'язка не видає нових прав, але дає новий СПОСІБ ними
// скористатися — з телефона, без входу в систему. Питання «чому
// алерт підтверджено о третій ночі акаунтом, який тоді нікуди
// не заходив» без цього рядка відповіді не має.
Label: "Прив'язка Telegram до облікового запису",
},
{
Key: AuditActionTelegramUnlink, Group: "Адміністрування",
Label: "Зняття прив'язки Telegram",
},
{
Key: AuditActionRetentionSettings, Group: "Адміністрування",
// Destructive не ставимо, і це не недогляд. Сама зміна нічого
// не стирає — стирає її наслідок, політика, яка вночі знесе
// чанки. Позначка «об'єкта більше немає» тут була б неправдою
// про мить події. Правду про наслідок несе meta: для кожного
// виду даних там стоїть shortened, тобто «строк скоротили», і
// саме за цим полем шукатимуть того, після кого зникла історія.
Label: "Зміна строків зберігання даних",
},
{
Key: AuditActionSLATargetSave, Group: "Адміністрування",
Label: "Зміна цілі SLA",
},
{
Key: AuditActionSLATargetDelete, Group: "Адміністрування",
// Тут Destructive стоїть, на відміну від строків зберігання, і
// різниця саме в миті: видалення цілі каскадом зносить УСІ її
// закриті періоди негайно, у цій же транзакції. Скільки саме —
// у meta.periods_deleted, бо через рік це буде єдине місце, де
// видно, що торішній звіт колись існував.
Label: "Видалення цілі SLA", Destructive: true,
},
{
Key: AuditActionSLAPeriodClose, Group: "Адміністрування",
// Закриття не руйнівне: воно, навпаки, робить число незмінним.
// А от meta.forced і meta.revision — те, за чим шукатимуть
// відповідь на «чому в мене два роздруки з різними числами».
Label: "Закриття періоду SLA",
},
{
Key: AuditActionBillingPlan, Group: "Тариф і ліцензія",
// Зміна тарифу нічого не стирає в мить збереження, але саме
// вона задає стелі — тобто з неї починається кожне «а чому в
// нас перестали заводитись хости». Питання ставлять за тиждень
// після події, і без цього рядка відповідь на нього не має де
// взятись: bill.entitlements переписується цілком, попереднього
// стану там не лишається. Тому в meta їдуть обидва тарифи, «з»
// і «на».
Label: "Зміна тарифу",
},
{
Key: AuditActionLicenseApply, Group: "Тариф і ліцензія",
// Найважливіший рядок розділу. Ключ задає стелі ВСІЄЇ
// інсталяції, включно з чужими кабінетами на спільному
// хостингу, і робить це одним натисканням. Самого ключа в meta
// немає й не буде — це секрет; там лише його ідентифікатор,
// кому виданий і до якої дати.
Label: "Застосування ліцензійного ключа",
},
{
Key: AuditActionLicenseClear, Group: "Тариф і ліцензія",
// Destructive не ставимо: рядок у bill.license_keys лишається,
// зникає лише те, що діє. Але наслідок відчутний — стелі
// повертаються до «не задані», — і саме тому дія в журналі.
Label: "Зняття ліцензійного ключа",
},
}
var auditActionByKey = func() map[string]AuditActionInfo {
m := make(map[string]AuditActionInfo, len(auditActions))
for _, a := range auditActions {
m[a.Key] = a
}
return m
}()
// AuditActions — словник для фільтра на сторінці.
func AuditActions() []AuditActionInfo { return auditActions }
// auditObjectTypes — назви типів об'єктів.
//
// Окремо від дій, бо це різні питання: дія — що зробили, тип — над чим.
// Один тип зачіпають кілька дій, і зводити їх в одну таблицю означало б
// повторювати назву об'єкта в кожному рядку.
//
// Перелік, а не мапа: порядок тут значущий — типи йдуть у тому ж
// порядку, у якому людина зустрічає їх у фільтрі дій, а мапа порядку не
// має. Раніше поруч лежав окремий список `order`, і будь-який новий тип
// мовчки не потрапляв у фільтр доти, доки його не допишуть удруге.
var auditObjectTypes = []AuditActionInfo{
{Key: AuditObjectDevice, Label: "Хост"},
{Key: AuditObjectCommandRun, Label: "Прогін команд"},
{Key: AuditObjectRollback, Label: "Відкат конфігу"},
{Key: AuditObjectRollbackPolicy, Label: "Правила погодження відкату"},
{Key: AuditObjectConfig, Label: "Версія конфігу"},
{Key: AuditObjectBackupDefaults, Label: "Налаштування бекапів"},
{Key: AuditObjectMirror, Label: "Дзеркало Git"},
{Key: AuditObjectRole, Label: "Роль"},
{Key: AuditObjectTelegram, Label: "Прив'язка Telegram"},
{Key: AuditObjectRetention, Label: "Строки зберігання даних"},
{Key: AuditObjectSLATarget, Label: "Ціль SLA"},
{Key: AuditObjectBillingPlan, Label: "Тариф"},
{Key: AuditObjectLicense, Label: "Ліцензія інсталяції"},
}
var auditObjectTypeByKey = func() map[string]string {
m := make(map[string]string, len(auditObjectTypes))
for _, o := range auditObjectTypes {
m[o.Key] = o.Label
}
return m
}()
// AuditObjectTypes — словник типів для фільтра.
func AuditObjectTypes() []AuditActionInfo { return auditObjectTypes }
// decorateAuditEvent дописує до події те, що знає словник.
func decorateAuditEvent(e *AuditEvent) {
if a, ok := auditActionByKey[e.Action]; ok {
e.ActionLabel = a.Label
e.ActionGroup = a.Group
e.Destructive = a.Destructive
} else {
// Ключа немає в словнику. Не вигадуємо назву з ключа — назва,
// зібрана з крапок, читається як фраза, але означає лише те, що
// хтось назвав змінну. Порожня мітка чесніша: сторінка покаже
// ключ і позначить, що назви для нього немає.
e.ActionGroup = auditActionGroupFromKey(e.Action)
}
if l, ok := auditObjectTypeByKey[e.ObjectType]; ok {
e.ObjectTypeLabel = l
}
}
// auditActionGroupFromKey — до якого розділу віднести незнайому дію.
//
// Це єдине, що з ключа справді виводиться: перший сегмент — схема бази,
// і вона не змінюється разом із формулюванням.
func auditActionGroupFromKey(key string) string {
head, _, _ := strings.Cut(key, ".")
switch head {
case "inv":
return "Інвентар"
case "ncm":
return "Конфігурації"
case "topo":
return "Топологія"
case "alr":
return "Сповіщення"
case "core":
return "Адміністрування"
case "bill":
return "Тариф і ліцензія"
default:
return ""
}
}
// AuditBlindSpots — чесний перелік того, чого журнал НЕ бачить.
//
// Сторінка без цього блоку створює хибне відчуття повноти: «тут нічого
// немає» читається як «нічого не робили», хоча означає лише «це місце
// продукту в журнал не пише». Найдорожча помилка журналу — не
// неправильний запис, а відсутній: неправильний помітно, відсутнього
// немає з чим порівняти.
//
// Перелік складено від протилежного до auditActions: усе, що не
// перелічено там, не записується. Рядки тримаються поруч зі словником
// саме тому, що правити їх треба разом — новий виклик WriteAudit має
// одночасно з'явитись у словнику й зникнути звідси.
func AuditBlindSpots() []string {
return []string{
"Вхід у систему, вихід і невдалі спроби входу. Спроби входу — і вдалі, і ні — лягають в окрему таблицю core.login_attempts, а не сюди: журнал аудиту вимагає tenant_id, а на момент перевірки пароля кабінет ще невідомий. Тобто цієї сторінки для питання «хто заходив» замало.",
"Поодинокі зміни хостів: створення, правка й видалення одного хоста в його картці. У журнал пишуть лише масові дії — а «видалив один хост» і «видалив сорок» відрізняються масштабом, не суттю. Виняток — повернення хоста з архіву й повне видалення: обидві йдуть масовим шляхом навіть для одного хоста, тож записуються завжди.",
"Склад команди: запрошення, зміна ролі людини, вилучення з кабінету. САМІ ролі — створення, зміна набору прав, видалення — з 0053 у журналі є (core.role.*), а от «кому цю роль видали» — ні. Тобто на питання «звідки в цієї людини такий доступ» журнал відповідає лише наполовину: що дозволяє роль, видно, хто в ній опинився — ні.",
"Доступи до обладнання: створення, правка й видалення облікових даних, а також те, кому їх призначили.",
"Мапи, групи, шаблони, правила алертів і канали сповіщень. Правка мапи оборотна й видима в самій мапі, але «хто вимкнув правило, за яким приходив алерт» звідси не видно.",
"Ручний запуск збору конфігу. Відкат конфігурації журнал бачить з 0060 — намір, погодження, відмову й зміну правил погодження, — а от «зібрати зараз» лишається поза ним: збір нічого не змінює на пристрої.",
"Читання. Журнал записує зміни, а не перегляди: те, що хтось відкрив чужий конфіг або вивантажив архів, тут не з'явиться — окрім вивантаження звіту про прогін команд.",
"Дії зондів і колектора: усе, що система робить сама за розкладом, журналом не покривається — це не дії людини, і їхнє місце в логах служб.",
}
}