Логін: - 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>
501 lines
15 KiB
Go
501 lines
15 KiB
Go
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
|
||
}
|