П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
573 lines
24 KiB
Go
573 lines
24 KiB
Go
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, "-_")
|
||
}
|