Netpulse_SasS/server/internal/httpapi/groups.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

398 lines
14 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 httpapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/netpulse/netpulse/server/internal/store"
)
// ---------------------------------------------------------------------
// Групи пристроїв
// ---------------------------------------------------------------------
func (s *Server) handleListDeviceGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {
return
}
groups, err := s.store.ListDeviceGroups(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік груп", err)
return
}
if groups == nil {
groups = []store.DeviceGroup{}
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
}
func (s *Server) handleCreateDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Name string `json:"name"`
Color string `json:"color"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
return
}
id, err := s.store.CreateDeviceGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Color)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "група з такою назвою вже є")
return
}
s.writeStoreError(w, "створення групи", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleDeleteDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
err := s.store.DeleteDeviceGroup(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення групи", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Хости
// ---------------------------------------------------------------------
var validDeviceKinds = map[string]bool{
"router": true, "switch": true, "firewall": true, "server": true,
"ap": true, "olt": true, "onu": true, "ups": true, "printer": true,
"camera": true, "sensor": true, "vm": true, "container": true, "other": true,
}
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
in, body, ok := decodeDevice(w, r)
if !ok {
return
}
id, err := s.store.CreateDevice(r.Context(), p.TenantID, in)
if err != nil {
if lim, ok := store.AsPlanLimit(err); ok {
// Текст складає БД — там, де перевірка й спрацювала, і з
// тими самими числами: «у тарифі Free дозволено 15 хостів,
// зараз 15». Числа їдуть іще й окремими полями, щоб форма
// могла показати їх поруч із кнопкою, а не лише в смужці
// помилки.
writeJSON(w, http.StatusPaymentRequired, map[string]any{
"error": map[string]any{
"code": "plan_limit",
"message": lim.Error(),
"limit": lim.Kind,
"plan": lim.Plan,
"allowed": lim.Allowed,
"used": lim.Used,
},
})
return
}
s.writeStoreError(w, "створення хоста", err)
return
}
// Перевірки й доступи — частина створення, а не окремий крок.
// Хост без жодної перевірки просто лежить у списку й ніколи не
// опитується; вимагати від людини другого запиту означало б робити
// такий стан типовим.
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, id, body.checkInputs()); err != nil {
s.writeStoreError(w, "перевірки хоста", err)
return
}
if body.CredentialIDs != nil {
if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, id, body.CredentialIDs); err != nil {
s.writeStoreError(w, "доступи хоста", err)
return
}
}
if body.TemplateIDs != nil {
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, id, body.TemplateIDs); err != nil {
s.writeStoreError(w, "шаблони хоста", err)
return
}
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
deviceID := r.PathValue("id")
// Право «редагувати пристрої» і право «редагувати ЦЕЙ пристрій» —
// різні речі: друге дають групи.
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
in, body, ok := decodeDevice(w, r)
if !ok {
return
}
if err := s.store.UpdateDevice(r.Context(), p.TenantID, deviceID, in); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
s.writeStoreError(w, "оновлення хоста", err)
return
}
if err := s.store.SetDeviceGroups(r.Context(), p.TenantID, deviceID, in.GroupIDs); err != nil {
s.writeStoreError(w, "групи хоста", err)
return
}
// nil означає «форма про це не говорила» — тоді не чіпаємо. Порожній
// масив означає «прибрати все», і це різні наміри.
if body.Checks != nil {
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, deviceID, body.checkInputs()); err != nil {
s.writeStoreError(w, "перевірки хоста", err)
return
}
}
if body.CredentialIDs != nil {
if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, deviceID, body.CredentialIDs); err != nil {
s.writeStoreError(w, "доступи хоста", err)
return
}
}
if body.TemplateIDs != nil {
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, deviceID, body.TemplateIDs); err != nil {
s.writeStoreError(w, "шаблони хоста", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
deviceID := r.PathValue("id")
if !p.Scope().CanWrite(deviceID) {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
return
}
if err := s.store.DeleteDevice(r.Context(), p.TenantID, deviceID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
return
}
s.writeStoreError(w, "видалення хоста", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// deviceBody — усе, що приходить із форми хоста, разом із тим, ЯК його
// опитувати. Хост без перевірок не опитується взагалі й лише займає
// місце в списку, тому форма має задавати їх одразу.
type deviceBody struct {
Name string `json:"name"`
Address string `json:"address"`
Kind string `json:"kind"`
Vendor string `json:"vendor"`
AgentID string `json:"agent_id"`
Enabled *bool `json:"enabled"`
GroupIDs []string `json:"group_ids"`
Notes string `json:"notes"`
Checks []struct {
ID string `json:"id"`
CheckType string `json:"check_type"`
Params json.RawMessage `json:"params"`
IntervalSec int `json:"interval_sec"`
TimeoutMs int `json:"timeout_ms"`
Retries int `json:"retries"`
Enabled *bool `json:"enabled"`
} `json:"checks"`
CredentialIDs []string `json:"credential_ids"`
TemplateIDs []string `json:"template_ids"`
}
func (b deviceBody) checkInputs() []store.CheckInput {
out := make([]store.CheckInput, 0, len(b.Checks))
for _, c := range b.Checks {
out = append(out, store.CheckInput{
ID: c.ID,
CheckType: c.CheckType,
Params: jsonOr(c.Params, "{}"),
IntervalSec: c.IntervalSec,
TimeoutMs: c.TimeoutMs,
Retries: c.Retries,
Enabled: c.Enabled == nil || *c.Enabled,
})
}
return out
}
func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, deviceBody, bool) {
var in deviceBody
if !decodeBody(w, r, &in) {
return store.DeviceInput{}, in, false
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва хоста")
return store.DeviceInput{}, in, false
}
if in.Kind == "" {
in.Kind = "other"
}
if !validDeviceKinds[in.Kind] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
return store.DeviceInput{}, in, false
}
return store.DeviceInput{
Name: strings.TrimSpace(in.Name),
Address: strings.TrimSpace(in.Address),
Kind: in.Kind,
Vendor: in.Vendor,
AgentID: in.AgentID,
Enabled: in.Enabled == nil || *in.Enabled,
GroupIDs: in.GroupIDs,
Notes: in.Notes,
}, in, true
}
// isPlanLimit прибрано навмисно, і це варто пояснити, бо функція
// виглядала робочою півроку.
//
// Вона перевіряла strings.Contains(err.Error(), "ліміт") — а тригер
// 0009 підіймав 'device limit reached for tenant % (limit %)', тобто
// англійською. Збігу не було ніколи. Тобто в мить, коли стеля вперше
// спрацювала б, людина отримала б 500 «внутрішня помилка» замість 402 з
// поясненням — рівно те, чого перевірка в БД мала не допустити.
//
// Помітити це неможливо було ніяк: bill.entitlements порожня на всіх
// інсталяціях, стеля не спрацьовувала жодного разу, і зелений код нічого
// про це не казав.
//
// Заміна (store.AsPlanLimit) впізнає відмову за HINT = 'upgrade_plan' —
// за полем, яке ставить сама перевірка, а не за текстом, який
// переписують, коли він погано читається.
// ---------------------------------------------------------------------
// Групи користувачів
// ---------------------------------------------------------------------
func (s *Server) handleListUserGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:read") {
return
}
groups, err := s.store.ListUserGroups(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік груп користувачів", err)
return
}
if groups == nil {
groups = []store.UserGroup{}
}
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
}
func (s *Server) handleCreateUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
Name string `json:"name"`
Description string `json:"description"`
}
if !decodeBody(w, r, &in) {
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
return
}
id, err := s.store.CreateUserGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Description)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "група з такою назвою вже є")
return
}
s.writeStoreError(w, "створення групи користувачів", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
// handlePatchUserGroup міняє склад і права одним запитом.
//
// Разом, а не окремими ендпоїнтами: у формі це один екран і одна
// кнопка «Зберегти», і розбивати її на два запити означало б лишати
// групу в напівзбереженому стані, якщо другий не пройде.
func (s *Server) handlePatchUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
MemberIDs *[]string `json:"member_ids"`
Permissions *map[string]string `json:"permissions"`
}
if !decodeBody(w, r, &in) {
return
}
groupID := r.PathValue("id")
if in.MemberIDs != nil {
if err := s.store.SetUserGroupMembers(r.Context(), p.TenantID, groupID, *in.MemberIDs); err != nil {
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
s.writeStoreError(w, "склад групи", err)
return
}
}
if in.Permissions != nil {
if err := s.store.SetUserGroupPermissions(r.Context(), p.TenantID, groupID, *in.Permissions); err != nil {
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
s.writeStoreError(w, "права групи", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
err := s.store.DeleteUserGroup(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення групи", err)
return
}
w.WriteHeader(http.StatusNoContent)
}