Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
590 lines
21 KiB
Go
590 lines
21 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
)
|
||
|
||
var ErrCheckNotFound = errors.New("перевірку не знайдено")
|
||
|
||
// CheckType — що взагалі вміє система опитувати.
|
||
//
|
||
// Перелік читається з БД, а не зашитий у фронтенд: плагіни додають свої
|
||
// типи міграцією, і UI має підхоплювати їх без перезбирання.
|
||
type CheckType struct {
|
||
Key string `json:"key"`
|
||
PluginKey string `json:"plugin_key"`
|
||
Name string `json:"name"`
|
||
ParamsSchema json.RawMessage `json:"params_schema"`
|
||
Metrics json.RawMessage `json:"metrics"`
|
||
/** Чи ввімкнений плагін у цього тенанта. */
|
||
Available bool `json:"available"`
|
||
// Чек заводить і планує система. З довідника не прибирається —
|
||
// перевірка типів при збереженні має його знати, — але в переліку
|
||
// для ручного додавання йому не місце: система вже завела свій,
|
||
// і другий такий самий упирався б у checks_uniq.
|
||
AutoManaged bool `json:"auto_managed"`
|
||
}
|
||
|
||
// Check — одна перевірка на хості.
|
||
type Check struct {
|
||
ID string `json:"id"`
|
||
DeviceID string `json:"device_id"`
|
||
CheckType string `json:"check_type"`
|
||
TypeName string `json:"type_name,omitempty"`
|
||
Params json.RawMessage `json:"params"`
|
||
IntervalSec int `json:"interval_sec"`
|
||
TimeoutMs int `json:"timeout_ms"`
|
||
Retries int `json:"retries"`
|
||
Enabled bool `json:"enabled"`
|
||
LastRunAt *string `json:"last_run_at,omitempty"`
|
||
LastError string `json:"last_error,omitempty"`
|
||
}
|
||
|
||
// ListCheckTypes віддає доступні типи перевірок.
|
||
func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckType, error) {
|
||
var out []CheckType
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT ct.key, ct.plugin_key::text, ct.name,
|
||
ct.params_schema::text, ct.metrics::text,
|
||
-- Плагін доступний, якщо його явно ввімкнули цьому
|
||
-- тенанту або він базовий: вимагати «встановлення»
|
||
-- для пінга означало б зустрічати кожного клієнта
|
||
-- порожнім списком перевірок.
|
||
(p.is_core OR COALESCE(pi.enabled, false)),
|
||
ct.auto_managed
|
||
FROM core.check_types ct
|
||
JOIN core.plugins p ON p.key = ct.plugin_key
|
||
LEFT JOIN core.plugin_installs pi
|
||
ON pi.plugin_key = ct.plugin_key AND pi.tenant_id = $1
|
||
ORDER BY ct.plugin_key, ct.key
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c CheckType
|
||
var schema, metrics string
|
||
if err := rows.Scan(&c.Key, &c.PluginKey, &c.Name, &schema, &metrics,
|
||
&c.Available, &c.AutoManaged); err != nil {
|
||
return err
|
||
}
|
||
c.ParamsSchema = json.RawMessage(schema)
|
||
c.Metrics = json.RawMessage(metrics)
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// ListDeviceChecks — перевірки одного хоста.
|
||
func (s *Store) ListDeviceChecks(ctx context.Context, tenantID, deviceID string) ([]Check, error) {
|
||
var out []Check
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT c.id::text, c.device_id::text, c.check_type, COALESCE(ct.name,''),
|
||
c.params::text, c.interval_sec, c.timeout_ms, c.retries, c.enabled,
|
||
to_char(c.last_run_at, 'YYYY-MM-DD"T"HH24:MI:SSOF'),
|
||
COALESCE(c.last_error,'')
|
||
FROM core.checks c
|
||
LEFT JOIN core.check_types ct ON ct.key = c.check_type
|
||
WHERE c.tenant_id = $1 AND c.device_id = $2
|
||
AND c.template_id IS NULL
|
||
ORDER BY c.check_type
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c Check
|
||
var params string
|
||
if err := rows.Scan(&c.ID, &c.DeviceID, &c.CheckType, &c.TypeName, ¶ms,
|
||
&c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled,
|
||
&c.LastRunAt, &c.LastError); err != nil {
|
||
return err
|
||
}
|
||
c.Params = json.RawMessage(params)
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// CheckInput — те, що приходить із форми. Порожній ID означає нову
|
||
// перевірку.
|
||
type CheckInput struct {
|
||
ID string
|
||
CheckType string
|
||
Params string
|
||
IntervalSec int
|
||
TimeoutMs int
|
||
Retries int
|
||
Enabled bool
|
||
}
|
||
|
||
// SetDeviceChecks замінює набір перевірок хоста цілком.
|
||
//
|
||
// Заміна, а не додавання: форма показує повний список, і прибраний
|
||
// рядок треба вміти прибрати.
|
||
//
|
||
// Наявні перевірки оновлюються за своїм id, а не перестворюються.
|
||
// Причина не в економії: унікальний індекс включає md5(params), тому
|
||
// «видалити й вставити» на зміні інтервалу спрацювало б, а на зміні
|
||
// параметрів створило б ДРУГУ перевірку того самого типу. Плюс
|
||
// перестворення скидає next_run_at і збиває рівномірність опитування
|
||
// по всьому парку.
|
||
func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, in []CheckInput) 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
|
||
}
|
||
|
||
keep := make([]string, 0, len(in))
|
||
for _, c := range in {
|
||
if c.IntervalSec < 5 {
|
||
c.IntervalSec = 60
|
||
}
|
||
if c.IntervalSec > 86400 {
|
||
c.IntervalSec = 86400
|
||
}
|
||
if c.TimeoutMs <= 0 {
|
||
c.TimeoutMs = 3000
|
||
}
|
||
if c.Retries < 0 {
|
||
c.Retries = 0
|
||
}
|
||
params := c.Params
|
||
if params == "" {
|
||
params = "{}"
|
||
}
|
||
|
||
var id string
|
||
if c.ID != "" {
|
||
err := tx.QueryRow(ctx, `
|
||
UPDATE core.checks
|
||
SET params = $4::jsonb, interval_sec = $5, timeout_ms = $6,
|
||
retries = $7, enabled = $8, updated_at = now()
|
||
WHERE tenant_id = $1 AND device_id = $2 AND id = $3
|
||
RETURNING id::text
|
||
`, tenantID, deviceID, c.ID, params, c.IntervalSec,
|
||
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id)
|
||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
|
||
}
|
||
}
|
||
|
||
// Рядка з таким id немає (або його не передали) — створюємо.
|
||
if id == "" {
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.checks
|
||
(tenant_id, device_id, check_type, params, interval_sec,
|
||
timeout_ms, retries, enabled)
|
||
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
|
||
RETURNING id::text
|
||
`, tenantID, deviceID, c.CheckType, params, c.IntervalSec,
|
||
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id); err != nil {
|
||
// Дубль ловимо окремо: checks_uniq спрацьовує на
|
||
// (хост, тип, інтерфейс, параметри), і для людини це
|
||
// не збій сервера, а «такий чек уже є». Без цієї
|
||
// гілки вона бачила б «внутрішню помилку» й не мала
|
||
// жодної підказки, що робити далі.
|
||
var pgErr *pgconn.PgError
|
||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||
return fmt.Errorf("%w: перевірка %s з такими ж параметрами "+
|
||
"у цього хоста вже є", ErrInvalid, c.CheckType)
|
||
}
|
||
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
|
||
}
|
||
}
|
||
keep = append(keep, id)
|
||
}
|
||
|
||
// Прибираємо зняте у формі. Перевірки на інтерфейсах не чіпаємо:
|
||
// їх заводить автовиявлення, і форма хоста про них не знає.
|
||
// Породжені шаблоном — так само: ними володіє реконсиляція, і
|
||
// видалення тут означало б, що вони зникають на кожне
|
||
// збереження форми, щоб за секунду з'явитися знову.
|
||
//
|
||
// І окремо — автокеровані типи. Їх ховають від переліку для
|
||
// ручного додавання, і саме тому вони НЕ приходять назад у
|
||
// цьому запиті: форма про них не знає, а отже не може їх
|
||
// «лишити». Без цієї умови кожне збереження форми тихо зносило
|
||
// б розпізнавання хоста й опитування портів — тобто ту саму
|
||
// автоматику, заради якої їх і сховали.
|
||
//
|
||
// Помилку внесено разом із auto_managed і знайдено на живому
|
||
// стенді: у п'яти хостів із шести зник topology.identify.
|
||
_, err := tx.Exec(ctx, `
|
||
DELETE FROM core.checks c
|
||
WHERE c.tenant_id = $1 AND c.device_id = $2
|
||
AND c.interface_id IS NULL
|
||
AND c.template_id IS NULL
|
||
AND NOT (c.id = ANY($3::uuid[]))
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM core.check_types ct
|
||
WHERE ct.key = c.check_type AND ct.auto_managed
|
||
)
|
||
`, tenantID, deviceID, nonNilIDs(keep))
|
||
return err
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Креденшели хоста
|
||
// ---------------------------------------------------------------------
|
||
|
||
type Credential struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Proto string `json:"proto"`
|
||
Username string `json:"username,omitempty"`
|
||
Port int `json:"port,omitempty"`
|
||
IsDefault bool `json:"is_default"`
|
||
}
|
||
|
||
func (s *Store) ListCredentials(ctx context.Context, tenantID string) ([]Credential, error) {
|
||
var out []Credential
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, name, proto::text, COALESCE(username,''),
|
||
COALESCE(port,0), is_default
|
||
FROM inv.credentials
|
||
WHERE tenant_id = $1
|
||
ORDER BY proto, name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c Credential
|
||
if err := rows.Scan(&c.ID, &c.Name, &c.Proto, &c.Username,
|
||
&c.Port, &c.IsDefault); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// CreateCredential зберігає доступ із зашифрованим секретом.
|
||
func (s *Store) CreateCredential(ctx context.Context, tenantID, name, proto, username string,
|
||
port int, secret string, options string, ring *crypto.Keyring) (string, error) {
|
||
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var secretID any
|
||
if secret != "" {
|
||
if ring == nil {
|
||
return errors.New("сервер запущено без ключа шифрування")
|
||
}
|
||
aad := tenantID + "|inv.credential"
|
||
sec, err := ring.Encrypt([]byte(secret), aad)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
kind := "snmp_v3"
|
||
switch proto {
|
||
case "snmp_v2c":
|
||
// Community — це не пароль SNMPv3, але шифрується так само:
|
||
// у дампі БД вона дає рівно той самий доступ до обладнання.
|
||
kind = "api_token"
|
||
case "ssh":
|
||
kind = "ssh_password"
|
||
case "telnet":
|
||
kind = "telnet"
|
||
}
|
||
var sid string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
|
||
RETURNING id::text
|
||
`, tenantID, kind, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).
|
||
Scan(&sid); err != nil {
|
||
return err
|
||
}
|
||
secretID = sid
|
||
}
|
||
|
||
opts := options
|
||
if opts == "" {
|
||
opts = "{}"
|
||
}
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO inv.credentials
|
||
(tenant_id, name, proto, username, port, secret_id, options)
|
||
VALUES ($1, $2, $3::inv.credential_proto, NULLIF($4,''), NULLIF($5,0), $6, $7::jsonb)
|
||
RETURNING id::text
|
||
`, tenantID, name, proto, username, port, secretID, opts).Scan(&id)
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
// SetDeviceCredentials замінює прив'язку доступів до хоста.
|
||
func (s *Store) SetDeviceCredentials(ctx context.Context, tenantID, deviceID string, credIDs []string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM inv.device_credentials dc
|
||
USING inv.credentials c
|
||
WHERE dc.credential_id = c.id AND dc.device_id = $1 AND c.tenant_id = $2
|
||
`, deviceID, tenantID); err != nil {
|
||
return err
|
||
}
|
||
if len(credIDs) == 0 {
|
||
return nil
|
||
}
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO inv.device_credentials (device_id, credential_id, priority)
|
||
SELECT $1, c.id, 100
|
||
FROM inv.credentials c
|
||
WHERE c.tenant_id = $2 AND c.id = ANY($3::uuid[])
|
||
ON CONFLICT DO NOTHING
|
||
`, deviceID, tenantID, credIDs)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// DeviceCredentialsOf — які доступи прив'язані до кожного хоста.
|
||
func (s *Store) DeviceCredentialsOf(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 dc.device_id::text, dc.credential_id::text
|
||
FROM inv.device_credentials dc
|
||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||
WHERE c.tenant_id = $1
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var d, c string
|
||
if err := rows.Scan(&d, &c); err != nil {
|
||
return err
|
||
}
|
||
out[d] = append(out[d], c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// UpdateCredential змінює доступ.
|
||
//
|
||
// Порожній secret означає «пароль лишити як є»: форма ніколи не показує
|
||
// збережений секрет (розшифрувати його для показу — це віддати його
|
||
// туди, звідки він уже не повернеться), тож надіслати незмінений вона не
|
||
// може навіть теоретично.
|
||
func (s *Store) UpdateCredential(ctx context.Context, tenantID, id, name, proto, username string,
|
||
port int, secret string, options string, ring *crypto.Keyring) error {
|
||
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var curProto string
|
||
var oldSecret *string
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT proto::text, secret_id::text FROM inv.credentials
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID).Scan(&curProto, &oldSecret); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return err
|
||
}
|
||
|
||
// Порожній proto означає «не чіпати». Зміна протоколу дозволена:
|
||
// community SNMP і пароль SSH зберігаються однаково, тож
|
||
// переналаштувати запис дешевше, ніж заводити новий і
|
||
// перевʼязувати всі хости.
|
||
if proto == "" {
|
||
proto = curProto
|
||
}
|
||
|
||
secretID := any(nil)
|
||
if oldSecret != nil {
|
||
secretID = *oldSecret
|
||
}
|
||
|
||
// Секрет зашифрований під видом старого протоколу. Якщо
|
||
// протокол змінили, а новий пароль не дали, збережений стає
|
||
// сміттям із чужим kind — тому вимагаємо ввести його заново.
|
||
if proto != curProto && secret == "" && oldSecret != nil {
|
||
return fmt.Errorf("%w: при зміні протоколу треба ввести пароль заново", ErrInvalid)
|
||
}
|
||
|
||
if secret != "" {
|
||
if ring == nil {
|
||
return errors.New("сервер запущено без ключа шифрування")
|
||
}
|
||
aad := tenantID + "|inv.credential"
|
||
sec, err := ring.Encrypt([]byte(secret), aad)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var sid string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
|
||
RETURNING id::text
|
||
`, tenantID, secretKind(proto), sec.KeyID, sec.Nonce, sec.Ciphertext,
|
||
sec.AuthTag, aad).Scan(&sid); err != nil {
|
||
return err
|
||
}
|
||
// Старий секрет прибираємо лише після того, як новий ліг:
|
||
// зворотний порядок на помилці шифрування лишив би доступ
|
||
// без пароля взагалі.
|
||
if oldSecret != nil {
|
||
if _, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *oldSecret); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
secretID = sid
|
||
}
|
||
|
||
opts := options
|
||
if opts == "" {
|
||
opts = "{}"
|
||
}
|
||
ct, err := tx.Exec(ctx, `
|
||
UPDATE inv.credentials
|
||
SET name = $3, proto = $8::inv.credential_proto,
|
||
username = NULLIF($4,''), port = NULLIF($5,0),
|
||
secret_id = $6, options = $7::jsonb
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID, name, username, port, secretID, opts, proto)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ct.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// DeleteCredential прибирає доступ разом із секретом.
|
||
//
|
||
// Прив'язки до хостів зникають каскадом. Це саме те, що треба: доступ,
|
||
// якого немає, не має лишатись у списку хоста й створювати враження, що
|
||
// пристрій опитується.
|
||
func (s *Store) DeleteCredential(ctx context.Context, tenantID, id string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var secretID *string
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT secret_id::text FROM inv.credentials WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID).Scan(&secretID); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM inv.credentials WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID); err != nil {
|
||
return err
|
||
}
|
||
if secretID != nil {
|
||
_, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *secretID)
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// CredentialUsage — скільки хостів користується доступом.
|
||
//
|
||
// Потрібне попередженню перед видаленням: «доступ використовують 34
|
||
// хости» — це інформація, а «Ви впевнені?» — ні.
|
||
func (s *Store) CredentialUsage(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 c.id::text, count(dc.device_id)
|
||
FROM inv.credentials c
|
||
LEFT JOIN inv.device_credentials dc ON dc.credential_id = c.id
|
||
WHERE c.tenant_id = $1
|
||
GROUP BY c.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
|
||
}
|
||
|
||
// secretKind — під яким видом секрет лягає в core.secrets.
|
||
//
|
||
// Community SNMPv2c — це не пароль, але шифрується так само: у дампі БД
|
||
// вона дає рівно той самий доступ до обладнання.
|
||
func secretKind(proto string) string {
|
||
switch proto {
|
||
case "snmp_v2c":
|
||
return "api_token"
|
||
case "ssh":
|
||
return "ssh_password"
|
||
case "telnet":
|
||
return "telnet"
|
||
default:
|
||
return "snmp_v3"
|
||
}
|
||
}
|
||
|
||
// DeviceCredentialIDs — які доступи прив'язані до хоста.
|
||
//
|
||
// Потрібне формі: показати порожній список там, де доступи задані,
|
||
// означало б стерти їх першим же збереженням.
|
||
func (s *Store) DeviceCredentialIDs(ctx context.Context, tenantID, deviceID string) ([]string, error) {
|
||
ids := []string{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT dc.credential_id::text
|
||
FROM inv.device_credentials dc
|
||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||
WHERE dc.device_id = $1 AND c.tenant_id = $2
|
||
ORDER BY dc.priority
|
||
`, deviceID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return ids, err
|
||
}
|