Netpulse_SasS/server/internal/store/groups.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

501 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"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 — м'яке видалення.
//
// Жорстке зняло б за собою всю телеметрію й історію алертів. Пристрій
// зникає з інтерфейсу, але його минуле лишається доступним для розбору.
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
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
}
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
}