Netpulse_SasS/server/internal/store/roles.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

573 lines
24 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"
"sort"
"strings"
"github.com/jackc/pgx/v5"
)
// Ролі та права: каталог для екрана й редактор власних ролей.
//
// Головна думка файлу — розділення двох джерел правди.
//
// БАЗА знає, ЩО означає право: core.permissions.description, фраза
// українською, переписана в 0053. Її не можна тримати в Go, бо права
// заводять міграції (0036 — ncm:exec, 0050 — audit:read), і опис
// з'являється в тій самій міграції, що й ключ.
//
// КОД знає, як право поводиться: до якої групи належить, чи воно
// незворотне і — найнеприємніше — чи його взагалі хтось перевіряє.
// Жодне з трьох не є властивістю рядка в таблиці: усе це видно лише
// з викликів requirePerm.
//
// Тому каталог збирається з обох: рядки з БД, ознаки — звідси.
// ---------------------------------------------------------------------
// Помилки
// ---------------------------------------------------------------------
var (
// ErrRoleSystem — спроба змінити вбудовану роль.
ErrRoleSystem = errors.New("вбудовану роль змінити не можна")
// ErrRoleInUse — у ролі ще є учасники.
ErrRoleInUse = errors.New("роль ще комусь призначена")
// ErrLastAdmin — запобіжник від самоблокування.
//
// Рятує від однієї конкретної катастрофи: після зміни в кабінеті не
// лишається ЖОДНОЇ живої людини з правом users:write. Повернути його
// нікому — саме право users:write і дозволяє правити ролі, тобто
// система замикається ззовні й відкривається лише руками в базі.
ErrLastAdmin = errors.New("у кабінеті не лишиться нікого, хто може керувати доступом")
)
// ---------------------------------------------------------------------
// Каталог прав
// ---------------------------------------------------------------------
// PermissionInfo — одне право так, як його бачить людина.
type PermissionInfo struct {
Key string `json:"key"`
// Description — з core.permissions, фраза з дієсловом.
Description string `json:"description"`
Group string `json:"group"`
// Sensitive — дія незворотна або зачіпає живе залізо. Не «важлива»:
// важливість суб'єктивна, а «після цього не можна відкотитись» —
// факт, і саме він має бути видимим у мить, коли ставлять галочку.
Sensitive bool `json:"sensitive,omitempty"`
// Dormant — ключ у базі є, а коду, який його питає, ще немає.
//
// Таких сьогодні шість із двадцяти чотирьох, і мовчати про це не
// можна: право, видане людині, нічого їй не відкриває й нічого не
// закриває. Той, хто зняв би devices:control сподіваючись
// заборонити ручний запуск перевірок, отримав би хибне відчуття
// безпеки — найдорожчий вид помилки саме в цій підсистемі.
Dormant bool `json:"dormant,omitempty"`
}
// PermissionGroup — права одного предмета.
type PermissionGroup struct {
Key string `json:"key"`
Title string `json:"title"`
Perms []PermissionInfo `json:"permissions"`
}
// permGroups — назви груп за префіксом ключа.
//
// Групування виведене з самого ключа: `<предмет>:<дія>`. Це єдине, що з
// ключа справді виводиться й не розходиться з формулюванням — так само,
// як у словнику дій аудиту.
//
// Плаский список із двадцяти чотирьох галочок уже не читається, а
// зростає він з кожною новою функцією продукту. Групи ж не треба
// вигадувати наперед: право нового плагіна `netflow:read` саме створить
// групу «netflow», і навіть без рядка в цій таблиці екран лишиться
// придатним — назвою групи стане префікс.
var permGroups = map[string]struct {
Title string
Order int
}{
"devices": {"Хости й метрики", 1},
"maps": {"Мапи топології", 2},
"alerts": {"Алерти й сповіщення", 3},
"ncm": {"Конфігурації обладнання", 4},
"agents": {"Зонди та збір даних", 5},
"dashboards": {"Дашборди", 6},
"users": {"Користувачі й ролі", 7},
"audit": {"Журнал аудиту", 8},
"settings": {"Налаштування організації", 9},
"billing": {"Тариф і оплата", 10},
}
// actionOrder — порядок прав усередині групи: від нешкідливого до того,
// після чого нічого не повернеш. За абеткою вийшло б «ack, delete, exec,
// read, write» — тобто найнебезпечніше в середині, де на нього ніхто не
// дивиться окремо.
var actionOrder = map[string]int{
"read": 1, "ack": 2, "write": 3, "control": 4,
"publish": 5, "rollback": 6, "exec": 7, "delete": 8, "manage": 9,
}
// sensitivePerms — права, наслідок яких незворотний або виходить за межі
// самого NetPulse (жива команда на залізі, гроші, чужі очі).
var sensitivePerms = map[string]bool{
"ncm:exec": true, // команда виконується на живому обладнанні
"ncm:rollback": true, // конфіг заливається назад на пристрій
"ncm:delete": true, // версії зникають без кошика
"devices:write": true, // масове видалення хостів разом з історією
"maps:publish": true, // мапа стає видимою без входу в систему
"users:write": true, // роздача доступу, зокрема собі подібним
"settings:write": true,
"billing:manage": true,
}
// dormantPerms — ключі, яких сьогодні не питає жоден обробник.
//
// Складено звіркою з усіма викликами requirePerm у server/internal/httpapi
// і з session.can() у web/src. Перелік НЕ обчислюється: у Go немає
// способу спитати «чи згадується цей рядок у коді», а вгадувати тут
// не можна — помилка в будь-який бік бреше людині про її ж безпеку.
//
// Рядок звідси зникає в тій самій правці, у якій з'являється перший
// requirePerm із цим ключем. Якщо забути — екран покаже попередження
// про право, яке вже працює; це помітно й нешкідливо. Зворотна помилка
// (право тихо не діє, а екран мовчить) саме та, від якої цей перелік і
// заведено.
var dormantPerms = map[string]bool{
"devices:control": true, // ручний запуск перевірок іде під devices:write
"maps:publish": true, // публічні посилання є лише в дашбордів
"ncm:rollback": true, // відкат на пристрій ще не реалізовано
// billing:read і billing:manage звідси прибрано: сторінка тарифу
// з'явилась, і обидва права тепер питає httpapi/billing.go.
}
// PermissionCatalog — усі права, згруповані, з описом із бази.
func (s *Store) PermissionCatalog(ctx context.Context) ([]PermissionGroup, error) {
rows, err := s.pool.Query(ctx,
`SELECT key, description FROM core.permissions`)
if err != nil {
return nil, err
}
defer rows.Close()
byGroup := map[string][]PermissionInfo{}
for rows.Next() {
var p PermissionInfo
if err := rows.Scan(&p.Key, &p.Description); err != nil {
return nil, err
}
subject, _, _ := strings.Cut(p.Key, ":")
p.Group = subject
p.Sensitive = sensitivePerms[p.Key]
p.Dormant = dormantPerms[p.Key]
byGroup[subject] = append(byGroup[subject], p)
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]PermissionGroup, 0, len(byGroup))
for subject, perms := range byGroup {
g := PermissionGroup{Key: subject, Title: subject, Perms: perms}
if meta, ok := permGroups[subject]; ok {
g.Title = meta.Title
}
sort.Slice(g.Perms, func(i, j int) bool {
_, ai, _ := strings.Cut(g.Perms[i].Key, ":")
_, aj, _ := strings.Cut(g.Perms[j].Key, ":")
oi, oj := actionOrder[ai], actionOrder[aj]
if oi != oj {
// Невідома дія (0) їде в кінець групи, а не на початок.
if oi == 0 {
return false
}
if oj == 0 {
return true
}
return oi < oj
}
return g.Perms[i].Key < g.Perms[j].Key
})
out = append(out, g)
}
sort.Slice(out, func(i, j int) bool {
oi, oj := permGroups[out[i].Key].Order, permGroups[out[j].Key].Order
if oi != oj {
if oi == 0 {
return false
}
if oj == 0 {
return true
}
return oi < oj
}
return out[i].Key < out[j].Key
})
return out, nil
}
// ---------------------------------------------------------------------
// Редактор ролей
// ---------------------------------------------------------------------
// RoleInput — те, що людина задає у формі ролі.
//
// Ключа тут немає навмисно: він створюється один раз із назви й далі не
// змінюється. Ключ — це те, за чим роль згадують у документації, у
// зверненні до підтримки й у майбутньому API; перейменування ролі не
// має ламати ці посилання.
type RoleInput struct {
Name string
Description string
Permissions []string
}
// RoleMemberCounts — скільки людей у кожній ролі кабінету.
//
// Питання «кого це зачепить» ставлять перед кожною правкою набору прав,
// а не лише перед видаленням. Роль без жодного учасника правлять
// сміливо; роль, у якій уся зміна, — з іншим настроєм.
func (s *Store) RoleMemberCounts(ctx context.Context, tenantID string) (map[string]int, error) {
out := map[string]int{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT role_id::text, count(*)::int
FROM core.memberships WHERE tenant_id = $1
GROUP BY role_id
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return err
}
out[id] = n
}
return rows.Err()
})
return out, err
}
// CreateRole заводить власну роль кабінету.
func (s *Store) CreateRole(ctx context.Context, tenantID string, in RoleInput) (Role, error) {
var out Role
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := checkNameFree(ctx, tx, tenantID, in.Name, ""); err != nil {
return err
}
key, err := freeRoleKey(ctx, tx, tenantID, in.Name)
if err != nil {
return err
}
// is_system не приймається ззовні взагалі: вбудованість — це не
// поле форми, а факт про інсталяцію. CHECK roles_system_is_global
// із 0053 не дав би записати true разом із tenant_id, але
// покладатись на нього означало б показати людині помилку бази
// замість відмови.
if err := tx.QueryRow(ctx, `
INSERT INTO core.roles (tenant_id, key, name, description, is_system)
VALUES ($1, $2, $3, NULLIF($4,''), false)
RETURNING id::text, key::text, name, COALESCE(description,''), is_system
`, tenantID, key, in.Name, in.Description).
Scan(&out.ID, &out.Key, &out.Name, &out.Description, &out.IsSystem); err != nil {
return mapPgError(err)
}
if err := setRolePermissions(ctx, tx, out.ID, in.Permissions); err != nil {
return err
}
// Перечитуємо, а не збираємо відповідь із того, що надіслали:
// у наборі могли бути повтори, і показати треба те, що справді
// лягло в базу.
saved, err := loadRole(ctx, tx, tenantID, out.ID)
if err != nil {
return err
}
out = saved
// Нова роль ще нікому не призначена, тож зламати доступ вона не
// може — перевіряти самоблокування тут нема від чого.
return nil
})
return out, err
}
// UpdateRole міняє назву, опис і набір прав власної ролі.
//
// Повертає стан ДО і ПІСЛЯ: без «було» рядок аудиту відповідає на
// питання «що тепер», а читають журнал заради «що змінилось».
func (s *Store) UpdateRole(ctx context.Context, tenantID, roleID string, in RoleInput) (before, after Role, err error) {
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
prev, err := loadRole(ctx, tx, tenantID, roleID)
if err != nil {
return err
}
if prev.IsSystem {
return ErrRoleSystem
}
before = prev
if err := checkNameFree(ctx, tx, tenantID, in.Name, roleID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE core.roles SET name = $2, description = NULLIF($3,'')
WHERE id = $1 AND tenant_id = $4
`, roleID, in.Name, in.Description, tenantID); err != nil {
return mapPgError(err)
}
if _, err := tx.Exec(ctx,
`DELETE FROM core.role_permissions WHERE role_id = $1`, roleID); err != nil {
return mapPgError(err)
}
if err := setRolePermissions(ctx, tx, roleID, in.Permissions); err != nil {
return err
}
// Перевірка ПІСЛЯ зміни й у тій самій транзакції: питання не
// «чи знімають users:write», а «чи лишиться хоч хтось із ним».
// Рахувати до зміни означало б рахувати не той стан, а окремою
// транзакцією — дозволити двом адмінам одночасно зняти право
// одне в одного, і обидва побачили б «усе гаразд».
if err := ensureSomeoneCanManage(ctx, tx, tenantID); err != nil {
return err
}
next, err := loadRole(ctx, tx, tenantID, roleID)
if err != nil {
return err
}
after = next
return nil
})
return before, after, err
}
// DeleteRole прибирає власну роль кабінету.
func (s *Store) DeleteRole(ctx context.Context, tenantID, roleID string) (Role, error) {
var gone Role
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
r, err := loadRole(ctx, tx, tenantID, roleID)
if err != nil {
return err
}
if r.IsSystem {
return ErrRoleSystem
}
gone = r
// Явна перевірка замість покладання на ON DELETE RESTRICT:
// відмова зовнішнього ключа виглядає як «violates foreign key
// constraint memberships_role_id_fkey», а людині треба знати не
// назву обмеження, а що робити далі — спершу перевести людей в
// іншу роль. Число в помилці й називає розмір цієї роботи.
var used int
if err := tx.QueryRow(ctx, `
SELECT count(*)::int FROM core.memberships
WHERE tenant_id = $1 AND role_id = $2
`, tenantID, roleID).Scan(&used); err != nil {
return err
}
if used > 0 {
return fmt.Errorf("%w: у ній ще %d учасник(ів)", ErrRoleInUse, used)
}
tag, err := tx.Exec(ctx,
`DELETE FROM core.roles WHERE id = $1 AND tenant_id = $2`, roleID, tenantID)
if err != nil {
return mapPgError(err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
// Роль порожня, тож нічиїх прав вона не несла — але перевірка
// коштує один індексний count, а помилка в цьому місці не
// лікується через інтерфейс узагалі.
return ensureSomeoneCanManage(ctx, tx, tenantID)
})
return gone, err
}
// ---------------------------------------------------------------------
// Внутрішнє
// ---------------------------------------------------------------------
// ensureSomeoneCanManage — той самий запобіжник від самоблокування.
//
// Рахує ЖИВИХ учасників кабінету, чия роль дає users:write. Не «ролі, у
// яких є право»: роль без жодного учасника нікого не рятує, а саме вона
// найчастіше й лишається після невдалої правки.
func ensureSomeoneCanManage(ctx context.Context, tx pgx.Tx, tenantID string) error {
var n int
if err := tx.QueryRow(ctx, `
SELECT count(*)::int
FROM core.memberships m
JOIN core.role_permissions rp
ON rp.role_id = m.role_id AND rp.permission_key = 'users:write'
JOIN core.users u ON u.id = m.user_id AND u.deleted_at IS NULL
WHERE m.tenant_id = $1
`, tenantID).Scan(&n); err != nil {
return err
}
if n == 0 {
return ErrLastAdmin
}
return nil
}
func loadRole(ctx context.Context, tx pgx.Tx, tenantID, roleID string) (Role, error) {
var r Role
err := tx.QueryRow(ctx, `
SELECT r.id::text, r.key::text, r.name, COALESCE(r.description,''), r.is_system,
COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key)
FILTER (WHERE rp.permission_key IS NOT NULL), '{}')
FROM core.roles r
LEFT JOIN core.role_permissions rp ON rp.role_id = r.id
WHERE r.id = $1 AND (r.tenant_id IS NULL OR r.tenant_id = $2)
GROUP BY r.id
`, roleID, tenantID).Scan(&r.ID, &r.Key, &r.Name, &r.Description, &r.IsSystem, &r.Permissions)
if errors.Is(err, pgx.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
// setRolePermissions розкладає набір прав. Невідомий ключ — помилка
// запиту, а не відмова зовнішнього ключа: 23503 не називає, який саме з
// двадцяти ключів помилковий.
func setRolePermissions(ctx context.Context, tx pgx.Tx, roleID string, perms []string) error {
seen := map[string]bool{}
for _, p := range perms {
if seen[p] {
continue
}
seen[p] = true
tag, err := tx.Exec(ctx, `
INSERT INTO core.role_permissions (role_id, permission_key)
SELECT $1, key FROM core.permissions WHERE key = $2
ON CONFLICT DO NOTHING
`, roleID, p)
if err != nil {
return mapPgError(err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: невідоме право %q", ErrInvalid, p)
}
}
return nil
}
func checkNameFree(ctx context.Context, tx pgx.Tx, tenantID, name, exceptID string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: у ролі має бути назва", ErrInvalid)
}
var taken bool
// Порівняння без урахування регістра й пробілів по краях: «Інженер»
// і «інженер » — це та сама роль для того, хто обирає її у списку,
// і два різні рядки для бази.
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM core.roles
WHERE (tenant_id IS NULL OR tenant_id = $1)
AND lower(btrim(name)) = lower(btrim($2))
AND ($3 = '' OR id <> $3::uuid)
)
`, tenantID, name, exceptID).Scan(&taken); err != nil {
return err
}
if taken {
return fmt.Errorf("%w: роль із такою назвою вже є", ErrInvalid)
}
return nil
}
// freeRoleKey робить ключ із назви.
//
// Ключ латиницею, бо core.slug цього вимагає, а сам домен придумано не
// для краси: ключ їде в адреси, у документацію та в конфіги інтеграцій,
// де кирилиця перетворюється на відсотки. Тому назву транслітеруємо, а
// не замінюємо випадковим рядком: «role-8f3ac1» не дає прочитати, про
// що йшлося, нікому — зокрема тому, хто читає журнал аудиту через рік.
func freeRoleKey(ctx context.Context, tx pgx.Tx, tenantID, name string) (string, error) {
base := slugify(name)
if base == "" {
base = "role"
}
for i := 0; i < 50; i++ {
key := base
if i > 0 {
key = fmt.Sprintf("%s-%d", trimSlug(base, 60), i+1)
}
var taken bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM core.roles
WHERE (tenant_id IS NULL OR tenant_id = $1) AND key = $2)
`, tenantID, key).Scan(&taken); err != nil {
return "", err
}
if !taken {
return key, nil
}
}
return "", fmt.Errorf("%w: не вдалося підібрати ключ для ролі", ErrInvalid)
}
// translit — українська й російська кирилиця в латиницю.
//
// Таблиця, а не бібліотека: вона потрібна рівно в одному місці й
// займає менше, ніж рядок імпорту в go.mod разом із його оновленнями.
var translit = map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "h", 'ґ': "g", 'д': "d", 'е': "e",
'є': "ie", 'ж': "zh", 'з': "z", 'и': "y", 'і': "i", 'ї': "i", 'й': "i",
'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r",
'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch",
'ш': "sh", 'щ': "shch", 'ь': "", 'ю': "iu", 'я': "ia", 'ъ': "", 'ы': "y",
'э': "e", 'ё': "e",
}
func slugify(s string) string {
var b strings.Builder
prevDash := true // щоб рядок не почався з дефіса
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prevDash = false
case translit[r] != "":
b.WriteString(translit[r])
prevDash = false
case r == 'ь' || r == 'ъ':
// М'який знак зникає, але слово не розривається.
default:
if !prevDash {
b.WriteByte('-')
prevDash = true
}
}
}
return trimSlug(b.String(), 64)
}
// trimSlug обрізає до довжини домену core.slug і прибирає дефіси з
// країв: CHECK вимагає, щоб рядок починався й закінчувався літерою або
// цифрою.
func trimSlug(s string, max int) string {
if len(s) > max {
s = s[:max]
}
return strings.Trim(s, "-_")
}