Netpulse_SasS/server/internal/httpapi/groups.go
byrsapty 48848bd3a5 Опитування хоста з форми й повне редагування користувачів
Форма створення хоста збирала назву, адресу, тип і групи — і не
збирала головного. Хост, доданий через UI, не опитувався взагалі:
жодного рядка в core.checks. Наявні хости на стенді працювали лише
тому, що їхні перевірки засіяні через SQL.

Опитування:
- POST /api/v1/devices приймає перевірки одразу; новий хост у формі
  починає з icmp.ping — єдиної перевірки, яка працює будь-де без
  налаштування
- поля параметрів будуються з params_schema, яку віддає сервер, а не
  з захардкодженого списку: інакше кожен новий тип від плагіна вимагав
  би перезбирання фронтенду
- правка йде за id, а не перестворенням: унікальний індекс включає
  md5(params), тож зміна параметрів створила б другу перевірку того
  самого типу
- доступи (SNMP-community, SSH) шифруються тим самим кільцем, що й
  секрети каналів, і прив'язуються до хоста

Користувачі:
- PATCH /api/v1/team/{id} міняє роль, логін, пошту, імʼя й пароль одним
  запитом; зміна пароля відкликає всі сесії
- профіль редагується лише в того, хто працює тільки в цій організації:
  core.users глобальна, і адмін філії не має міняти пароль тому, хто
  тим самим акаунтом заходить у сусідню — той навіть не дізнався б.
  Спроба дає 409 shared_user

Знайдено при написанні: core.plugins не має колонки enabled — активація
на тенанта живе в core.plugin_installs, і вона порожня, тому базові
плагіни доступні без явного встановлення.

Живий прогін: 8 типів із 9, три перевірки записались, правка інтервалу
не задвоїла, зміна пароля пустила новим і відхилила старий, зміна ролі
собі — 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:22:56 +03:00

360 lines
12 KiB
Go
Raw 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 isPlanLimit(err) {
writeError(w, http.StatusPaymentRequired, "plan_limit",
"вичерпано ліміт пристроїв тарифу")
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
}
}
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
}
}
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"`
}
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
}
func isPlanLimit(err error) bool {
return err != nil && strings.Contains(err.Error(), "ліміт")
}
// ---------------------------------------------------------------------
// Групи користувачів
// ---------------------------------------------------------------------
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)
}