Netpulse_SasS/server/internal/httpapi/groups.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

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

298 lines
9.3 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 (
"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, 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
}
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, 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
}
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)
}
func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, bool) {
var in 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"`
}
if !decodeBody(w, r, &in) {
return store.DeviceInput{}, false
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва хоста")
return store.DeviceInput{}, false
}
if in.Kind == "" {
in.Kind = "other"
}
if !validDeviceKinds[in.Kind] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
return store.DeviceInput{}, 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,
}, 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)
}