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

836 lines
33 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 (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// Читання журналу аудиту.
//
// Окремо від audit.go навмисно: там одна функція, яка кладе подію, і
// вона не має знати нічого про те, як журнал потім розглядають. Запис
// іде з десятка місць продукту й мусить лишатись дешевим і незмінним;
// читання — це фільтри, курсор і словник, який житиме своїм життям.
//
// Головне обмеження шару: RLS на core.audit_log НЕ ДІЄ й діяти не
// може — це гіпертаблиця, а TimescaleDB не поєднує row level security
// зі стисненням (0011). Тому предикат tenant_id стоїть у КОЖНОМУ запиті
// нижче явно, як єдиний механізм ізоляції, а не як дублювання.
//
// Виняток — два LEFT JOIN на core.users і core.api_tokens, якими
// журнал добирає імена акторів. Ці таблиці під політиками, тож обидва
// читання йдуть через InTenantTx. Без контексту вони не впали б, а
// повернули NULL: перелік подій лишився б, а колонка «хто» стала б
// порожньою. Це єдине місце в шарі, де забутий контекст дає не
// порожній екран, а правдоподібну неправду.
// AuditFilter — питання, з яким приходять на сторінку журналу.
type AuditFilter struct {
// Період обов'язковий і завжди заданий клієнтом. Журнал росте
// вічно, і запит без нижньої межі — це запит по всій історії
// організації; те, що він зазвичай швидкий завдяки курсору, не
// робить його осмисленим.
From time.Time
To time.Time
Actions []string
ObjectTypes []string
// Хто саме — двома окремими переліками, бо це два різні стовпці й
// два різні види діяча. Обрані разом вони складаються через АБО:
// питання «що робили Петро й наша інтеграція» — одне питання.
ActorUserIDs []string
ActorTokenIDs []string
// AnyToken — «будь-який машинний токен», тобто рядок без людини.
// Потрібне окремо від переліку токенів: відкликаний токен зі списку
// зникає, а його сліди в журналі лишаються назавжди — і саме до них
// найчастіше й буває питання.
AnyToken bool
// Адреса або підмережа: '10.20.0.5' і '10.20.0.0/24' обидві
// працюють через один оператор <<=.
ActorIP string
// Пошук підрядком по before/after/meta. Саме там лежать імена
// хостів і тексти команд — тобто те, за чим журнал і шукають.
Query string
Limit int
Cursor string
}
// AuditActor — хто зробив дію.
//
// Користувач і токен окремими полями, а не одним «хто»: за токеном
// інтеграції немає людини, і зводити їх до рядка означало б втратити
// різницю між «інженер натиснув кнопку» і «скрипт ходить нашим API».
// Ім'я користувача підтягується на момент ЧИТАННЯ — якщо людину
// перейменували, у журналі має бути видно поточне ім'я, бо саме за ним
// її впізнають сьогодні.
type AuditActor struct {
UserID string `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
TokenID string `json:"token_id,omitempty"`
TokenName string `json:"token_name,omitempty"`
IP string `json:"ip,omitempty"`
}
// AuditEvent — рядок журналу в переліку.
//
// Без before/after/meta: у прогоні команд на п'ятсот хостів meta несе
// п'ятсот ідентифікаторів та імен, і п'ятдесят таких рядків — це
// мегабайти на кожне гортання. Замість вмісту тут його стислий переказ
// (Summary) і чесна вага (Bytes); повний вміст приїздить окремо, коли
// на запис натиснули.
type AuditEvent struct {
ID string `json:"id"`
TS time.Time `json:"ts"`
Action string `json:"action"`
// Людська назва дії. Порожня для ключа, якого немає в словнику, —
// і це не помилка: журнал старший за словник, і рядок, назви якому
// ще не придумали, має бути видно як є.
ActionLabel string `json:"action_label,omitempty"`
ActionGroup string `json:"action_group,omitempty"`
// Руйнівна дія: те, після чого об'єкта більше немає. Саме за цим
// журнал переглядають найчастіше.
Destructive bool `json:"destructive,omitempty"`
ObjectType string `json:"object_type,omitempty"`
ObjectTypeLabel string `json:"object_type_label,omitempty"`
ObjectID string `json:"object_id,omitempty"`
Actor AuditActor `json:"actor"`
Summary string `json:"summary,omitempty"`
// Скільки важать before/after/meta разом. Показується поруч із
// кнопкою розгортання: людина має бачити, що зараз завантажить.
Bytes int `json:"bytes"`
HasBefore bool `json:"has_before"`
HasAfter bool `json:"has_after"`
HasDetails bool `json:"has_details"`
}
// AuditEventDetail — те, що показують по натисканню.
type AuditEventDetail struct {
AuditEvent
Before json.RawMessage `json:"before,omitempty"`
After json.RawMessage `json:"after,omitempty"`
Meta json.RawMessage `json:"meta,omitempty"`
}
// AuditPage — сторінка журналу.
type AuditPage struct {
// Період, у якому відповідь насправді зібрана. Повертається назад
// тому, що межі мають типові значення («останні 30 днів»), а
// «останні» — це момент першого запиту. Клієнт закріплює отриману
// пару й шле її з кожною наступною сторінкою: інакше подія, що
// сталася під час гортання, зсунула б усю вибірку на рядок.
From time.Time `json:"from"`
To time.Time `json:"to"`
Events []AuditEvent `json:"events"`
// Курсор наступної сторінки. Порожній означає «більше немає» —
// саме означає, а не «здається»: сторінка береться з limit+1
// рядка, тож питання «чи є ще» відповідає сама вибірка, а не
// окремий COUNT по всьому журналу.
NextCursor string `json:"next_cursor,omitempty"`
}
const (
auditDefaultLimit = 50
auditMaxLimit = 200
)
// ---------------------------------------------------------------------
// Курсор
// ---------------------------------------------------------------------
// Курсор — це пара (ts, id) останнього показаного рядка, а не номер
// сторінки.
//
// OFFSET на журналі означає «прочитати сто тисяч рядків, щоб показати
// двадцять», і ціна росте разом із глибиною. Виміряно на 500 тис.
// рядків: курсор на 100 000-му рядку — 1.3 мс і 147 буферів, той самий
// рядок через OFFSET 100000 — 46 мс і 100 416 буферів.
//
// Пара, а не сам лише ts: дві події однієї дії отримують один ts із
// точністю до мікросекунди, і межа лише за часом або губила б другий
// рядок, або показувала б перший двічі.
//
// Мікросекунди, а не RFC3339 з наносекундами: timestamptz у Postgres
// має саме мікросекундну точність, тож так пара повертається в базу
// точно такою, якою звідти приїхала.
func encodeAuditCursor(ts time.Time, id string) string {
return strconv.FormatInt(ts.UnixMicro(), 10) + "." + id
}
func decodeAuditCursor(s string) (time.Time, string, error) {
micros, id, ok := strings.Cut(s, ".")
if !ok {
return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid)
}
n, err := strconv.ParseInt(micros, 10, 64)
if err != nil {
return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid)
}
if !isUUID(id) {
return time.Time{}, "", fmt.Errorf("%w: зіпсований курсор", ErrInvalid)
}
return time.UnixMicro(n).UTC(), id, nil
}
func isUUID(s string) bool {
if len(s) != 36 {
return false
}
for i, c := range s {
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
continue
}
if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') {
return false
}
}
return true
}
// ---------------------------------------------------------------------
// Вибірка
// ---------------------------------------------------------------------
// ListAudit повертає сторінку журналу.
func (s *Store) ListAudit(ctx context.Context, tenantID string, f AuditFilter) (AuditPage, error) {
limit := f.Limit
if limit <= 0 {
limit = auditDefaultLimit
}
if limit > auditMaxLimit {
limit = auditMaxLimit
}
where, args, err := auditWhere(tenantID, f)
if err != nil {
return AuditPage{}, err
}
// Беремо на один рядок більше, ніж показуємо: саме він і є
// відповіддю на «чи є наступна сторінка». Окремий COUNT(*) по
// журналу коштував би повного проходу за фільтром — тобто рівно
// того, чого курсор і уникає.
args = append(args, limit+1)
limitArg := len(args)
inner := `
SELECT ts, id, action, object_type, object_id,
actor_user_id, actor_token_id, host(actor_ip) AS actor_ip,
(before IS NOT NULL) AS has_before,
(after IS NOT NULL) AS has_after,
coalesce(octet_length(before::text), 0) +
coalesce(octet_length(after::text), 0) +
coalesce(octet_length(meta::text), 0) AS bytes,
meta
FROM core.audit_log
WHERE ` + where
if f.Query != "" {
// Пошук по вмісту ставиться в підзапит із OFFSET 0.
//
// Це бар'єр планувальника, і без нього план виходить
// катастрофічним саме в найчастішому випадку. З ORDER BY ts
// DESC + LIMIT планувальник обирає впорядкований індекс і
// розраховує зупинитись, щойно набере двадцять рядків, — а
// коли шуканого підрядка в журналі немає (а це і є типовий
// результат пошуку за іменем хоста), він доходить до кінця
// історії, жодного разу не заглянувши в триграмний індекс.
//
// Виміряно на 500 тис. рядків, пошук без збігів за два роки:
// 634 мс і 167 368 буферів проти 64 мс і 6 016 із бар'єром.
// Ціною є те, що широкий запит («Бровари») збирає всі збіги й
// сортує їх — 42 мс на тих самих даних; це вища й передбачувана
// стеля замість нижчої й випадкової.
inner += "\n\t\tOFFSET 0"
inner = "SELECT * FROM (" + inner + "\n\t\t) q"
}
q := `
WITH page AS (` + inner + `
ORDER BY ts DESC, id DESC
LIMIT $` + strconv.Itoa(limitArg) + `
)
SELECT p.ts, p.id, p.action, p.object_type, p.object_id,
p.actor_user_id, p.actor_token_id, p.actor_ip,
p.has_before, p.has_after, p.bytes, p.meta,
u.username, u.email, t.name
FROM page p
LEFT JOIN core.users u ON u.id = p.actor_user_id
LEFT JOIN core.api_tokens t ON t.id = p.actor_token_id AND t.tenant_id = $1
ORDER BY p.ts DESC, p.id DESC`
// Тенантна транзакція потрібна не журналу, а двом LEFT JOIN у
// хвості запиту. core.audit_log — гіпертаблиця, RLS на ній немає, і
// сам перелік прочитався б і без контексту. А от core.users і
// core.api_tokens під політиками: без app.tenant_id обидва JOIN
// дали б NULL, і журнал показував би ті самі рядки з порожньою
// колонкою «хто». Це єдиний тут випадок, коли забутий контекст дає
// не порожній екран, а правдоподібну неправду — журнал, у якому всі
// дії анонімні.
out := AuditPage{From: f.From, To: f.To, Events: []AuditEvent{}}
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, q, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
e, err := scanAuditEvent(rows)
if err != nil {
return err
}
out.Events = append(out.Events, e)
}
return rows.Err()
})
if err != nil {
return AuditPage{}, err
}
if len(out.Events) > limit {
last := out.Events[limit-1]
out.Events = out.Events[:limit]
out.NextCursor = encodeAuditCursor(last.TS, last.ID)
}
return out, nil
}
// auditWhere складає умову й аргументи. $1 завжди tenant_id.
func auditWhere(tenantID string, f AuditFilter) (string, []any, error) {
args := []any{tenantID}
// Предикат tenant_id стоїть тут, а не покладається на RLS, і це не
// перестраховка: політик на core.audit_log немає взагалі й не буде
// (гіпертаблиця). Прибрати цей рядок означає показати один кабінет
// іншому — і після 0063 це лишається правдою рівно для журналу й
// решти гіпертаблиць, тоді як усюди ще й спрацює політика.
cond := []string{"tenant_id = $1"}
add := func(sql string, v any) {
args = append(args, v)
cond = append(cond, fmt.Sprintf(sql, len(args)))
}
if !f.From.IsZero() {
add("ts >= $%d", f.From)
}
if !f.To.IsZero() {
add("ts < $%d", f.To)
}
if len(f.Actions) > 0 {
add("action = ANY($%d)", f.Actions)
}
if len(f.ObjectTypes) > 0 {
add("object_type = ANY($%d)", f.ObjectTypes)
}
// Актори: обране з різних стовпців зводиться в одну умову через АБО.
// Окремими умовами воно означало б перетин («що робив Петро ЧЕРЕЗ
// цей токен»), а такого рядка не буває взагалі — актор у події
// рівно один.
var actorOr []string
if len(f.ActorUserIDs) > 0 {
args = append(args, f.ActorUserIDs)
actorOr = append(actorOr, fmt.Sprintf("actor_user_id = ANY($%d)", len(args)))
}
if len(f.ActorTokenIDs) > 0 {
args = append(args, f.ActorTokenIDs)
actorOr = append(actorOr, fmt.Sprintf("actor_token_id = ANY($%d)", len(args)))
}
if f.AnyToken {
actorOr = append(actorOr, "actor_user_id IS NULL")
}
if len(actorOr) > 0 {
cond = append(cond, "("+strings.Join(actorOr, " OR ")+")")
}
if ip := strings.TrimSpace(f.ActorIP); ip != "" {
// <<= бере і точну адресу, і підмережу: одна умова замість
// двох режимів у інтерфейсі.
add("actor_ip <<= $%d::inet", ip)
}
if q := strings.TrimSpace(f.Query); q != "" {
// Один вираз над трьома колонками — рівно той, на якому стоїть
// триграмний індекс із 0050. Три окремі ILIKE через OR дали б
// три сканування й BitmapOr замість одного.
add(`(coalesce(meta::text, '') || ' ' ||
coalesce(before::text, '') || ' ' ||
coalesce(after::text, '')) ILIKE $%d`, "%"+escapeLike(q)+"%")
}
if f.Cursor != "" {
ts, id, err := decodeAuditCursor(f.Cursor)
if err != nil {
return "", nil, err
}
args = append(args, ts, id)
cond = append(cond, fmt.Sprintf("(ts, id) < ($%d, $%d)", len(args)-1, len(args)))
}
return strings.Join(cond, "\n\t\t AND "), args, nil
}
// escapeLike знешкоджує символи шаблону.
//
// Без цього набране в полі пошуку «%» знаходить геть усе, а «_» —
// будь-який символ: людина шукає підрядок, а не пише шаблон, і
// підсунути їй мову шаблонів мовчки означає давати неправильні
// відповіді на правильні питання.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func scanAuditEvent(rows pgx.Rows) (AuditEvent, error) {
var (
e AuditEvent
objType *string
objID *string
userID *string
tokenID *string
ip *string
meta []byte
username *string
email *string
tokenName *string
)
if err := rows.Scan(&e.TS, &e.ID, &e.Action, &objType, &objID,
&userID, &tokenID, &ip,
&e.HasBefore, &e.HasAfter, &e.Bytes, &meta,
&username, &email, &tokenName); err != nil {
return AuditEvent{}, err
}
// deref живе в autoassign.go — той самий пакет, той самий сенс;
// друга копія на п'ять рядків розійшлася б із першою рівно тоді,
// коли одну з них комусь захочеться «покращити».
e.TS = e.TS.UTC()
e.ObjectType = deref(objType)
e.ObjectID = deref(objID)
e.Actor = AuditActor{
UserID: deref(userID),
Username: deref(username),
Email: deref(email),
TokenID: deref(tokenID),
TokenName: deref(tokenName),
IP: deref(ip),
}
decorateAuditEvent(&e)
e.Summary = auditSummary(meta)
e.HasDetails = e.Bytes > 2 // «{}» — це не подробиці
return e, nil
}
// GetAuditEvent повертає один запис разом із before/after/meta.
//
// ts у ключі не примха: первинний ключ гіпертаблиці — (ts, id), і без
// часу пошук за самим лише id означав би перебір усіх чанків. Клієнт
// його завжди має — він щойно бачив цей рядок у переліку.
func (s *Store) GetAuditEvent(ctx context.Context, tenantID, id string, ts time.Time) (AuditEventDetail, error) {
if !isUUID(id) {
return AuditEventDetail{}, fmt.Errorf("%w: некоректний ідентифікатор", ErrInvalid)
}
var (
d AuditEventDetail
objType *string
objID *string
userID *string
tokenID *string
ip *string
before []byte
after []byte
meta []byte
username *string
email *string
tokenName *string
)
// Транзакція — заради тих самих двох LEFT JOIN, що й у ListAudit.
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT a.ts, a.id, a.action, a.object_type, a.object_id,
a.actor_user_id, a.actor_token_id, host(a.actor_ip),
(a.before IS NOT NULL), (a.after IS NOT NULL),
coalesce(octet_length(a.before::text), 0) +
coalesce(octet_length(a.after::text), 0) +
coalesce(octet_length(a.meta::text), 0),
a.before, a.after, a.meta,
u.username, u.email, t.name
FROM core.audit_log a
LEFT JOIN core.users u ON u.id = a.actor_user_id
LEFT JOIN core.api_tokens t ON t.id = a.actor_token_id AND t.tenant_id = $1
WHERE a.tenant_id = $1 AND a.id = $2 AND a.ts = $3
`, tenantID, id, ts).Scan(&d.TS, &d.ID, &d.Action, &objType, &objID,
&userID, &tokenID, &ip,
&d.HasBefore, &d.HasAfter, &d.Bytes,
&before, &after, &meta,
&username, &email, &tokenName)
})
if errors.Is(err, pgx.ErrNoRows) {
return AuditEventDetail{}, ErrNotFound
}
if err != nil {
return AuditEventDetail{}, err
}
d.TS = d.TS.UTC()
d.ObjectType = deref(objType)
d.ObjectID = deref(objID)
d.Actor = AuditActor{
UserID: deref(userID),
Username: deref(username),
Email: deref(email),
TokenID: deref(tokenID),
TokenName: deref(tokenName),
IP: deref(ip),
}
decorateAuditEvent(&d.AuditEvent)
d.Summary = auditSummary(meta)
d.HasDetails = d.Bytes > 2
d.Before = json.RawMessage(before)
d.After = json.RawMessage(after)
d.Meta = json.RawMessage(meta)
return d, nil
}
// ---------------------------------------------------------------------
// Межі журналу
// ---------------------------------------------------------------------
// AuditBounds — від якої дати в журналі взагалі щось є.
//
// Потрібне не для краси. Писати в journal почали недавно, і порожня
// відповідь на «покажи березень» означає «у березні ще не писали», а не
// «у березні нічого не робили». Різницю між цими двома реченнями
// сторінка мусить показати сама, інакше вона тихо бреше.
type AuditBounds struct {
First *time.Time `json:"first,omitempty"`
Last *time.Time `json:"last,omitempty"`
}
func (s *Store) AuditBounds(ctx context.Context, tenantID string) (AuditBounds, error) {
var b AuditBounds
// Два окремі запити з LIMIT 1, а не min()/max() одним рядком:
// впорядкований обхід гіпертаблиці зупиняється на першому ж чанку,
// у якому щось є, а агрегат на кожному краю читав би всі.
err := s.pool.QueryRow(ctx, `
SELECT ts FROM core.audit_log WHERE tenant_id = $1
ORDER BY ts ASC, id ASC LIMIT 1
`, tenantID).Scan(&b.First)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return AuditBounds{}, err
}
err = s.pool.QueryRow(ctx, `
SELECT ts FROM core.audit_log WHERE tenant_id = $1
ORDER BY ts DESC, id DESC LIMIT 1
`, tenantID).Scan(&b.Last)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return AuditBounds{}, err
}
if b.First != nil {
t := b.First.UTC()
b.First = &t
}
if b.Last != nil {
t := b.Last.UTC()
b.Last = &t
}
return b, nil
}
// AuditActorOption — рядок у списку «хто саме».
type AuditActorOption struct {
ID string `json:"id"`
Label string `json:"label"`
// Kind: user | token.
Kind string `json:"kind"`
}
// AuditActors — список для фільтра «хто».
//
// Береться зі складу кабінету й з переліку токенів, а не з DISTINCT по
// самому журналу. DISTINCT по колонці, яка стоїть другою в індексі,
// Postgres 16 без skip-scan виконує повним проходом — тобто ціна списку
// для випадаючого меню дорівнювала б ціні найважчого запиту сторінки.
// А головне, список зі складу кабінету відповідає на потрібне питання
// («хто в нас є») навіть тоді, коли людина ще нічого не зробила.
func (s *Store) AuditActors(ctx context.Context, tenantID string) ([]AuditActorOption, error) {
out := []AuditActorOption{}
// Обидві таблиці під RLS, тому без контексту список «хто» був би
// порожній, а фільтр журналу — непрацездатний.
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT u.id, u.username, coalesce(u.email::text, '')
FROM core.memberships m
JOIN core.users u ON u.id = m.user_id
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
ORDER BY u.username
`, tenantID)
if err != nil {
return err
}
for rows.Next() {
var id, username, email string
if err := rows.Scan(&id, &username, &email); err != nil {
rows.Close()
return err
}
label := username
if email != "" {
label = username + " · " + email
}
out = append(out, AuditActorOption{ID: id, Label: label, Kind: "user"})
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
trows, err := tx.Query(ctx, `
SELECT id, name FROM core.api_tokens
WHERE tenant_id = $1 AND revoked_at IS NULL
ORDER BY name
`, tenantID)
if err != nil {
return err
}
defer trows.Close()
for trows.Next() {
var id, name string
if err := trows.Scan(&id, &name); err != nil {
return err
}
out = append(out, AuditActorOption{ID: id, Label: name, Kind: "token"})
}
return trows.Err()
})
if err != nil {
return nil, err
}
return out, nil
}
// ---------------------------------------------------------------------
// Стислий переказ вмісту
// ---------------------------------------------------------------------
// auditSummary робить із meta один рядок для переліку.
//
// Узагальнено, без гілки на кожну дію. Гілка на дію означала б, що
// рядок, доданий у продукт завтра, у переліку виглядатиме порожнім
// доти, доки хтось не згадає дописати сюди ще один case — а помітити це
// можна лише випадково, дивлячись саме на цю дію.
//
// Порядок ключів заданий явно: у meta вони йдуть як у Go-мапі, тобто
// випадково, і без сортування той самий рядок при кожному читанні
// виглядав би інакше. Спочатку — те, що відповідає на «скільки й чого»,
// решта за абеткою.
func auditSummary(meta []byte) string {
if len(meta) < 3 {
return ""
}
var m map[string]any
if err := json.Unmarshal(meta, &m); err != nil || len(m) == 0 {
return ""
}
// Ключі, які найкраще переказують дію. Технічні переліки
// ідентифікаторів (device_ids) свідомо не показуються: у переліку
// вони витісняють імена, а імена й є те, що людина впізнає. Самі
// ідентифікатори нікуди не діваються — вони у повному вмісті, який
// приїздить по натисканню.
// name і username стоять першими: у записах про правило, канал,
// драбину чи учасника це єдине, що дає впізнати об'єкт з переліку.
// Решта полів там — подробиці правки, і читають їх уже після того,
// як знайшли потрібний рядок.
preferred := []string{
"name", "username",
"devices", "deleted", "updated", "stopped", "names", "commands",
"counts", "status", "versions", "format", "enabled", "freed_bytes",
"created_by", "filter",
}
skip := map[string]bool{"device_ids": true}
seen := map[string]bool{}
order := make([]string, 0, len(m))
for _, k := range preferred {
if _, ok := m[k]; ok && !skip[k] {
order = append(order, k)
seen[k] = true
}
}
rest := make([]string, 0, len(m))
for k := range m {
if !seen[k] && !skip[k] {
rest = append(rest, k)
}
}
sort.Strings(rest)
order = append(order, rest...)
parts := make([]string, 0, 4)
for _, k := range order {
if len(parts) == 4 {
break
}
if v := auditValueText(m[k]); v != "" {
parts = append(parts, auditKeyLabel(k)+": "+v)
}
}
return strings.Join(parts, " · ")
}
// auditKeyLabel — назва ключа meta українською.
//
// Словник лише на ті ключі, які продукт справді кладе в meta сьогодні;
// невідомий ключ показується як є. Це навмисно: переказ, у якому
// незнайомий ключ мовчки зник би, гірший за переказ, у якому видно
// англійське слово. Повний вміст по натисканню показує сирі ключі
// завжди — саме там правда, а тут її переказ.
var auditKeyLabels = map[string]string{
"devices": "хостів",
"names": "хости",
"commands": "команди",
"counts": "результат",
"status": "стан",
"deleted": "видалено",
"updated": "змінено",
"stopped": "не почато",
"versions": "версії",
"kept": "лишилось",
"freed_bytes": "звільнено байтів",
"format": "формат",
"enabled": "увімкнено",
"days": "днів",
"filter": "фільтр",
"concurrency": "паралельно",
"timeout_sec": "таймаут, с",
"created_by": "створив",
"created_at": "створено",
"dropped": "відкинуто",
"checks": "перевірки",
"success": "успішно",
"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 {
if l, ok := auditKeyLabels[k]; ok {
return l
}
return k
}
// auditValueText — коротке подання одного значення.
func auditValueText(v any) string {
switch t := v.(type) {
case nil:
return ""
case bool:
if t {
return "так"
}
return "ні"
case float64:
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'f', -1, 64)
case string:
return clip(t, 60)
case []any:
if len(t) == 0 {
return ""
}
head := make([]string, 0, 2)
for _, e := range t {
if len(head) == 2 {
break
}
if s := auditValueText(e); s != "" {
head = append(head, s)
}
}
s := strings.Join(head, ", ")
if len(t) > len(head) {
s += fmt.Sprintf(" (+%d)", len(t)-len(head))
}
return clip(s, 80)
case map[string]any:
// Вкладений об'єкт розкривається на один рівень, а не рахується.
// «{1}» не каже нічого; «успішно 1» — це і є те, чим прогін
// закінчився, і саме за цим у перелік дивляться.
if len(t) == 0 {
return ""
}
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
head := make([]string, 0, 3)
for _, k := range keys {
if len(head) == 3 {
break
}
if s := auditValueText(t[k]); s != "" {
head = append(head, auditKeyLabel(k)+" "+s)
}
}
if len(head) == 0 {
return ""
}
s := strings.Join(head, ", ")
if len(keys) > len(head) {
s += fmt.Sprintf(" (+%d)", len(keys)-len(head))
}
return clip(s, 60)
default:
return ""
}
}
func clip(s string, max int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= max {
return string(r)
}
return string(r[:max]) + "…"
}