Netpulse_SasS/server/internal/store/map_access.go
byrsapty 65b37a28ff
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння
go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на
пристрій за ідентифікатором (ім'я змінюють, історія не має від цього
розсипатись), файл за іменем. Однаковий вміст нового коміту не створює.

netpulse-gitsync переливає накопичену історію й відтворює втрачений
репозиторій із бази — тіла конфігів там і так лежать зашифрованими.

У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів
(?from=), інтерфейс — ні.

Дорогою виправлено чотири тести, які CI запустив уперше з базою.
Серед них справжня помилка: машинний токен не міг читати мапи —
ACL мап отримував порожній рядок замість uuid і давав 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:14 +03:00

188 lines
6.3 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"
"github.com/jackc/pgx/v5"
)
// MapPermission — рівень доступу групи до мапи.
type MapPermission struct {
GroupID string `json:"group_id"`
GroupName string `json:"group_name,omitempty"`
Level string `json:"level"`
}
// MapAccess каже, що людина може робити з мапою.
//
// Порожній набір дозволів на мапі означає «доступна всім, хто має
// maps:read»: у типовій інсталяції мапа одна на всіх, і вимагати
// налаштувати доступ до неї означало б зробити типовий випадок
// найдовшим.
//
// Порожній userID — це машинний токен, а не анонім. Токен не належить
// до жодної групи, тож груповий ACL до нього не застосовний: його межі
// задають scopes, які перевіряє обробник. Повертати йому «deny» означало
// б зламати інтеграції першою ж налаштованою групою на мапі.
func (s *Store) MapAccess(ctx context.Context, tenantID, userID, mapID string) (string, error) {
if userID == "" {
return "write", nil
}
var level string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx,
`SELECT topo.map_access_level($1, $2)::text`, userID, mapID).Scan(&level)
})
return level, err
}
// ListMapPermissions — хто має доступ до мапи.
func (s *Store) ListMapPermissions(ctx context.Context, tenantID, mapID string) ([]MapPermission, error) {
out := []MapPermission{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT mp.group_id::text, g.name, mp.level::text
FROM topo.map_permissions mp
JOIN core.user_groups g ON g.id = mp.group_id
WHERE mp.map_id = $1
ORDER BY g.name
`, mapID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var p MapPermission
if err := rows.Scan(&p.GroupID, &p.GroupName, &p.Level); err != nil {
return err
}
out = append(out, p)
}
return rows.Err()
})
return out, err
}
// SetMapPermissions замінює набір доступів мапи цілком.
//
// Порожній список повертає мапу в стан «доступна всім». Це видно у
// формі прямим текстом — мовчазне перетворення обмеженої мапи на
// загальнодоступну було б найгіршим способом втратити межі.
func (s *Store) SetMapPermissions(ctx context.Context, tenantID, mapID string, perms []MapPermission) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM topo.maps
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, mapID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
if _, err := tx.Exec(ctx,
`DELETE FROM topo.map_permissions WHERE map_id = $1`, mapID); err != nil {
return err
}
for _, p := range perms {
if p.Level == "" {
p.Level = "read"
}
if _, err := tx.Exec(ctx, `
INSERT INTO topo.map_permissions (map_id, group_id, tenant_id, level)
VALUES ($1, $2, $3, $4::core.access_level)
ON CONFLICT (map_id, group_id) DO UPDATE SET level = EXCLUDED.level
`, mapID, p.GroupID, tenantID, p.Level); err != nil {
return err
}
}
return nil
})
}
// ---------------------------------------------------------------------
// Іконки
// ---------------------------------------------------------------------
// Icon — власна картинка для вузлів мапи.
type Icon struct {
ID string `json:"id"`
Name string `json:"name"`
Mime string `json:"mime"`
Size int `json:"size_bytes"`
}
// ListIcons — довідник іконок без самих байтів.
func (s *Store) ListIcons(ctx context.Context, tenantID string) ([]Icon, error) {
out := []Icon{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, name, mime, octet_length(bytes)
FROM topo.icons WHERE tenant_id = $1 ORDER BY name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var i Icon
if err := rows.Scan(&i.ID, &i.Name, &i.Mime, &i.Size); err != nil {
return err
}
out = append(out, i)
}
return rows.Err()
})
return out, err
}
// IconBytes віддає саму картинку.
func (s *Store) IconBytes(ctx context.Context, tenantID, id string) ([]byte, string, error) {
var b []byte
var mime string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT bytes, mime FROM topo.icons WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&b, &mime)
})
if err != nil {
if isNoRows(err) {
return nil, "", ErrNotFound
}
return nil, "", err
}
return b, mime, nil
}
// CreateIcon зберігає картинку.
func (s *Store) CreateIcon(ctx context.Context, tenantID, userID, name, mime string, data []byte) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
INSERT INTO topo.icons (tenant_id, name, mime, bytes, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id::text
`, tenantID, name, mime, data, nullUUID(userID)).Scan(&id)
})
return id, err
}
// DeleteIcon прибирає картинку.
//
// Вузли, що на неї посилались, лишаються — просто повертаються до
// значка за типом. Каскадом чистити style у JSON довелося б обходом
// усіх мап, а користі з цього рівно стільки ж.
func (s *Store) DeleteIcon(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`DELETE FROM topo.icons WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}