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