Netpulse_SasS/server/internal/store/groups.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

539 lines
18 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"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var ErrGroupNotFound = errors.New("групу не знайдено")
// ---------------------------------------------------------------------
// Групи пристроїв
// ---------------------------------------------------------------------
type DeviceGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Color string `json:"color,omitempty"`
DeviceCount int `json:"device_count"`
}
func (s *Store) ListDeviceGroups(ctx context.Context, tenantID string) ([]DeviceGroup, error) {
var out []DeviceGroup
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT g.id::text, g.name, g.kind::text, COALESCE(g.color,''),
(SELECT count(*) FROM inv.device_group_members m
JOIN inv.devices d ON d.id = m.device_id AND d.deleted_at IS NULL
WHERE m.group_id = g.id)::int
FROM inv.device_groups g
WHERE g.tenant_id = $1
ORDER BY g.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var g DeviceGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Kind, &g.Color, &g.DeviceCount); err != nil {
return err
}
out = append(out, g)
}
return rows.Err()
})
return out, err
}
func (s *Store) CreateDeviceGroup(ctx context.Context, tenantID, name, color string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO inv.device_groups (tenant_id, name, kind, color)
VALUES ($1, $2, 'static', NULLIF($3,''))
RETURNING id::text
`, tenantID, name, color).Scan(&id)
})
return id, err
}
func (s *Store) DeleteDeviceGroup(ctx context.Context, tenantID, groupID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx,
`DELETE FROM inv.device_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrGroupNotFound
}
return nil
})
}
// SetDeviceGroups замінює членство пристрою цілком.
//
// Саме заміна, а не додавання: інтерфейс показує повний набір галочок,
// і зняту галочку треба вміти зняти. Часткові операції довелося б
// узгоджувати з тим, що бачить людина, і будь-яке розходження
// закінчувалося б непоміченим доступом.
func (s *Store) SetDeviceGroups(ctx context.Context, tenantID, deviceID string, groupIDs []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM inv.devices
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, deviceID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `
DELETE FROM inv.device_group_members m
USING inv.device_groups g
WHERE m.group_id = g.id AND m.device_id = $1 AND g.tenant_id = $2
`, deviceID, tenantID); err != nil {
return err
}
if len(groupIDs) == 0 {
return nil
}
// Групи звіряються з тенантом усередині запиту: інакше чужий id
// у списку тихо додав би пристрій у групу сусіда.
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_group_members (group_id, device_id)
SELECT g.id, $1 FROM inv.device_groups g
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, deviceID, tenantID, groupIDs)
return err
})
}
// ---------------------------------------------------------------------
// Групи користувачів і права на групи пристроїв
// ---------------------------------------------------------------------
type UserGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
MemberIDs []string `json:"member_ids"`
Permissions map[string]string `json:"permissions"` // device_group_id → deny|read|write
}
func (s *Store) ListUserGroups(ctx context.Context, tenantID string) ([]UserGroup, error) {
var out []UserGroup
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT g.id::text, g.name, COALESCE(g.description,''),
COALESCE(array_agg(DISTINCT m.user_id::text)
FILTER (WHERE m.user_id IS NOT NULL), '{}')
FROM core.user_groups g
LEFT JOIN core.user_group_members m ON m.group_id = g.id
WHERE g.tenant_id = $1
GROUP BY g.id, g.name, g.description
ORDER BY g.name
`, tenantID)
if err != nil {
return err
}
byID := map[string]int{}
for rows.Next() {
var g UserGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberIDs); err != nil {
rows.Close()
return err
}
g.Permissions = map[string]string{}
byID[g.ID] = len(out)
out = append(out, g)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(out) == 0 {
return nil
}
perms, err := tx.Query(ctx, `
SELECT p.user_group_id::text, p.device_group_id::text, p.level::text
FROM core.group_permissions p
JOIN core.user_groups g ON g.id = p.user_group_id
WHERE g.tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer perms.Close()
for perms.Next() {
var ug, dg, lvl string
if err := perms.Scan(&ug, &dg, &lvl); err != nil {
return err
}
if i, ok := byID[ug]; ok {
out[i].Permissions[dg] = lvl
}
}
return perms.Err()
})
return out, err
}
func (s *Store) CreateUserGroup(ctx context.Context, tenantID, name, description string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO core.user_groups (tenant_id, name, description)
VALUES ($1, $2, NULLIF($3,''))
RETURNING id::text
`, tenantID, name, description).Scan(&id)
})
return id, err
}
func (s *Store) DeleteUserGroup(ctx context.Context, tenantID, groupID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx,
`DELETE FROM core.user_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrGroupNotFound
}
return nil
})
}
// SetUserGroupMembers і SetUserGroupPermissions — теж повна заміна, з
// тієї самої причини, що й у пристроїв.
func (s *Store) SetUserGroupMembers(ctx context.Context, tenantID, groupID string, userIDs []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`DELETE FROM core.user_group_members WHERE group_id = $1`, groupID); err != nil {
return err
}
if len(userIDs) == 0 {
return nil
}
// Лише ті, хто справді є в цьому тенанті: додати чужого
// користувача в свою групу означало б видати йому доступ.
_, err := tx.Exec(ctx, `
INSERT INTO core.user_group_members (group_id, user_id)
SELECT $1, m.user_id
FROM core.memberships m
WHERE m.tenant_id = $2 AND m.user_id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, groupID, tenantID, userIDs)
return err
})
}
func (s *Store) SetUserGroupPermissions(ctx context.Context, tenantID, groupID string, perms map[string]string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
return err
}
if _, err := tx.Exec(ctx,
`DELETE FROM core.group_permissions WHERE user_group_id = $1`, groupID); err != nil {
return err
}
for dg, lvl := range perms {
switch lvl {
case "deny", "read", "write":
default:
return fmt.Errorf("невідомий рівень доступу %q", lvl)
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.group_permissions (user_group_id, device_group_id, level)
SELECT $1, g.id, $3::core.access_level
FROM inv.device_groups g
WHERE g.id = $2 AND g.tenant_id = $4
`, groupID, dg, lvl, tenantID); err != nil {
return err
}
}
return nil
})
}
func assertUserGroup(ctx context.Context, tx pgx.Tx, tenantID, groupID string) error {
var ok bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.user_groups WHERE id = $1 AND tenant_id = $2)
`, groupID, tenantID).Scan(&ok); err != nil {
return err
}
if !ok {
return ErrGroupNotFound
}
return nil
}
// ---------------------------------------------------------------------
// Обмеження видимості
// ---------------------------------------------------------------------
// Scope — що саме цій людині видно.
//
// Обчислюється раз на запит і далі йде параметром у вибірки. Питати
// БД про кожен пристрій окремо означало б перетворити список на N
// запитів рівно тоді, коли пристроїв багато.
type Scope struct {
// Unrestricted — людина в жодній групі користувачів, тож групами не
// обмежена; доступ визначає роль. Див. коментар у міграції 0013.
Unrestricted bool
Readable []string
Writable []string
}
func (s Scope) CanWrite(deviceID string) bool {
if s.Unrestricted {
return true
}
for _, id := range s.Writable {
if id == deviceID {
return true
}
}
return false
}
func (s Scope) CanRead(deviceID string) bool {
if s.Unrestricted {
return true
}
for _, id := range s.Readable {
if id == deviceID {
return true
}
}
return false
}
// LoadScope рахує доступні пристрої одним запитом.
func (s *Store) LoadScope(ctx context.Context, tenantID, userID string) (Scope, error) {
sc := Scope{Unrestricted: true}
if userID == "" {
// Машинний токен групами не обмежений: у нього немає членства,
// а його межі задає власний scopes.
return sc, nil
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var inGroups bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM core.user_group_members m
JOIN core.user_groups g ON g.id = m.group_id
WHERE m.user_id = $1 AND g.tenant_id = $2)
`, userID, tenantID).Scan(&inGroups); err != nil {
return err
}
if !inGroups {
return nil
}
sc.Unrestricted = false
for _, lvl := range []string{"read", "write"} {
rows, err := tx.Query(ctx,
`SELECT device_id::text FROM core.accessible_devices($1, $2, $3::core.access_level)`,
userID, tenantID, lvl)
if err != nil {
return err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if lvl == "read" {
sc.Readable = ids
} else {
sc.Writable = ids
}
}
return nil
})
return sc, err
}
// ---------------------------------------------------------------------
// Хости
// ---------------------------------------------------------------------
type DeviceInput struct {
Name string
Address string
Kind string
Vendor string
AgentID string
SiteID string
Enabled bool
GroupIDs []string
Notes string
}
func (s *Store) CreateDevice(ctx context.Context, tenantID string, in DeviceInput) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
INSERT INTO inv.devices
(tenant_id, name, address, kind, vendor, agent_id, site_id, enabled, notes, source)
VALUES ($1, $2, NULLIF($3,'')::inet, $4::inv.device_kind, NULLIF($5,''),
$6, $7, $8, NULLIF($9,''), 'manual')
RETURNING id::text
`, tenantID, in.Name, in.Address, in.Kind, in.Vendor,
nullUUID(in.AgentID), nullUUID(in.SiteID), in.Enabled, in.Notes).Scan(&id); err != nil {
return fmt.Errorf("створення хоста: %w", err)
}
if len(in.GroupIDs) == 0 {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_group_members (group_id, device_id)
SELECT g.id, $1 FROM inv.device_groups g
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, id, tenantID, in.GroupIDs)
return err
})
return id, err
}
func (s *Store) UpdateDevice(ctx context.Context, tenantID, deviceID string, in DeviceInput) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE inv.devices
SET name = $3, address = NULLIF($4,'')::inet, kind = $5::inv.device_kind,
vendor = NULLIF($6,''), agent_id = $7, enabled = $8,
notes = NULLIF($9,''), updated_at = now()
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
`, tenantID, deviceID, in.Name, in.Address, in.Kind, in.Vendor,
nullUUID(in.AgentID), in.Enabled, in.Notes)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// DeleteDevice — АРХІВНЕ видалення: хост зникає з інтерфейсу, зібране
// лишається в базі.
//
// Це одна з двох дій, а не «видалення взагалі». Друга — PurgeDevices
// (devices_purge.go): вона стирає хост разом із телеметрією, архівом
// конфігів і гілкою в Git. Обидві законні, різниця незворотна, тому
// обирає її людина у вікні підтвердження, а не код тут.
//
// Що лишається після цієї: телеметрія, версії конфігів, історія
// алертів, вузли на мапах. Що припиняється: опитування, збір конфігів,
// нові алерти.
//
// Ця ручка лишається архівною й тоді, коли її смикають з API напряму.
// Незворотну дію не роблять параметром запиту, який легко не помітити:
// повне видалення йде окремим шляхом (POST /devices/bulk-delete з
// mode=purge), де перед ним обов'язково стоїть перелік із числами.
func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
tag, err := tx.Exec(ctx, `
UPDATE inv.devices
SET deleted_at = now(), enabled = false, archived_enabled = enabled
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
`, tenantID, deviceID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
// Чеки вимикаємо разом із хостом.
//
// План їх і так не бере (BuildPlan фільтрує deleted_at), тож
// на опитування це не впливає. Впливає на все інше, що дивиться
// в core.checks: увімкнений чек, який ніколи не виконується,
// виглядає як затор — і сторінка «Черги» чесно показує його
// червоним рівно доти, доки на неї не перестануть дивитись.
//
// Не видаляємо, а вимикаємо: хост відновлюють разом із його
// налаштуваннями, а відновити те, чого немає, нема з чого.
//
// archived_off позначає, що чек вимкнула саме ця дія, а не
// людина. Без позначки повернення хоста мусило б або вмикати
// все підряд — разом із тим, що тримали вимкненим свідомо, —
// або не вмикати нічого, і тоді хост повертався б у переліки
// мовчазним. Умова `AND enabled` тут і є те, що робить
// позначку правдивою: вимкнене до архівування нею не
// позначається.
if _, err := tx.Exec(ctx, `
UPDATE core.checks
SET enabled = false, archived_off = true, updated_at = now()
WHERE tenant_id = $1 AND device_id = $2 AND enabled
`, tenantID, deviceID); err != nil {
return err
}
return nil
})
}
// DeviceGroupsOf — у яких групах пристрій. Потрібне формі редагування.
func (s *Store) DeviceGroupsOf(ctx context.Context, tenantID string) (map[string][]string, error) {
out := map[string][]string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT m.device_id::text, m.group_id::text
FROM inv.device_group_members m
JOIN inv.device_groups g ON g.id = m.group_id
WHERE g.tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var d, g string
if err := rows.Scan(&d, &g); err != nil {
return err
}
out[d] = append(out[d], g)
}
return rows.Err()
})
return out, err
}
// nonNilIDs віддає порожній масив замість nil.
//
// pgx перетворює nil-слайс на SQL NULL, а `id = ANY(NULL)` — це не
// «жоден», а NULL, тобто рядок не проходить умову, але й помилки немає.
// Такий фільтр мовчки ховав би всі пристрої.
func nonNilIDs(ids []string) []string {
if ids == nil {
return []string{}
}
return ids
}