Логін: - 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>
298 lines
9.3 KiB
Go
298 lines
9.3 KiB
Go
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)
|
||
}
|
||
|