Netpulse_SasS/server/internal/store/audit_actions.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

572 lines
35 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 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"
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"
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"
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"
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: 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: "Створення ролі",
},
{
Key: AuditActionRoleUpdate, Group: "Адміністрування",
Label: "Зміна прав ролі",
},
{
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: "Адміністрування",
// Прив'язка не видає нових прав, але дає новий СПОСІБ ними
// скористатися — з телефона, без входу в систему. Питання «чому
// алерт підтверджено о третій ночі акаунтом, який тоді нікуди
// не заходив» без цього рядка відповіді не має.
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: 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"},
{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, а на момент перевірки пароля кабінет ще невідомий. Тобто цієї сторінки для питання «хто заходив» замало.",
"Поодинокі зміни хостів: створення, правка й видалення одного хоста в його картці. У журнал пишуть лише масові дії — а «видалив один хост» і «видалив сорок» відрізняються масштабом, не суттю. Виняток — повернення хоста з архіву й повне видалення: обидві йдуть масовим шляхом навіть для одного хоста, тож записуються завжди.",
"Видимість учасника: перелік груп, якими обмежено його доступ (memberships.scope_group_ids). Хто в кабінеті й з якою роллю, журнал бачить (core.member.*), а от звуження чи розширення видимості всередині ролі — ні; зараз його й нема чим змінити через API, тож рядок стоїть тут на випадок, коли така ручка з'явиться.",
"Доступи до обладнання: створення, правка й видалення облікових даних, а також те, кому їх призначили.",
"Мапи, групи й шаблони. Правка тут оборотна й видима в самому об'єкті: мапа зберігає ревізії й уміє відкат, склад групи видно у групі, у шаблона є час останньої правки. Хто саме правив — не видно, і це свідома межа, а не пропуск. Правила алертів, канали сповіщень і драбини ескалації з цього рядка прибрано: вони в журналі є (alr.*), бо їхня правка не лишає по собі нічого — вимкнене правило виглядає точно так само, як правило, за яким просто нічого не сталось.",
"Тригери, породжені шаблоном. Шаблон описує правило один раз на клас пристроїв, і правлять його там; окремого сліду про кожне похідне правило журнал не пише — інакше одна правка шаблону давала б сотню однакових рядків, за якими не видно самої правки.",
"Пробне повідомлення в канал (кнопка «Перевірити»). Нічого не змінює й нічого не вмикає — це діагностика, а не дія над каналом.",
"Ручний запуск збору конфігу. Відкат конфігурації журнал бачить з 0060 — намір, погодження, відмову й зміну правил погодження, — а от «зібрати зараз» лишається поза ним: збір нічого не змінює на пристрої.",
"Читання. Журнал записує зміни, а не перегляди: те, що хтось відкрив чужий конфіг або вивантажив архів, тут не з'явиться — окрім вивантаження звіту про прогін команд.",
"Дії зондів і колектора: усе, що система робить сама за розкладом, журналом не покривається — це не дії людини, і їхнє місце в логах служб.",
}
}