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

205 lines
7 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"
"regexp"
"strings"
"github.com/netpulse/netpulse/server/internal/store"
)
// usernameRe повторює CHECK із міграції 0013. Дублювання свідоме:
// відмова БД виглядає як «violates check constraint users_username_format»,
// і людині з неї нічого не зрозуміло.
var usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$`)
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:read") {
return
}
team, err := s.store.ListTeam(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік команди", err)
return
}
if team == nil {
team = []store.TeamMember{}
}
writeJSON(w, http.StatusOK, map[string]any{"members": team})
}
func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:read") {
return
}
roles, err := s.store.ListRoles(r.Context(), p.TenantID)
if err != nil {
s.writeStoreError(w, "перелік ролей", err)
return
}
if roles == nil {
roles = []store.Role{}
}
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
}
// handleCreateUser додає людину в тенант.
//
// Поки що з паролем, який задає адміністратор. Запрошення поштою
// (`core.invitations` у схемі готова) додамо разом із відправкою листів —
// без робочої пошти воно перетворилось би на глухий кут в інтерфейсі.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"full_name"`
RoleKey string `json:"role_key"`
}
if !decodeBody(w, r, &in) {
return
}
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
if in.Username == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "invalid", "потрібні логін і пароль")
return
}
// Формат перевіряє й БД, але помилка звідти виглядає як
// «violates check constraint» — людині з неї нічого не зрозуміло.
if !usernameRe.MatchString(in.Username) {
writeError(w, http.StatusBadRequest, "bad_username",
"логін: 364 символи, лише латиниця, цифри, крапка, дефіс і підкреслення")
return
}
if in.RoleKey == "" {
in.RoleKey = "viewer"
}
// Роль owner не роздається через API: власник один, і міняється він
// передачею тенанта, а не додаванням другого власника.
if in.RoleKey == "owner" {
writeError(w, http.StatusForbidden, "forbidden", "роль owner не призначається через API")
return
}
u, err := s.store.CreateUser(r.Context(), p.TenantID, in.Username, in.Email,
in.Password, in.FullName, in.RoleKey)
if err != nil {
s.writeStoreError(w, "створення користувача", err)
return
}
writeJSON(w, http.StatusCreated, u)
}
// handleSetRole міняє роль і профіль учасника.
//
// Один ендпоїнт, а не три: у формі це один екран і одна кнопка
// «Зберегти». Розбивати її на окремі запити означало б лишати
// користувача в напівзбереженому стані, якщо другий не пройде.
func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
var in struct {
RoleID string `json:"role_id"`
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
Password string `json:"password"`
}
if !decodeBody(w, r, &in) {
return
}
userID := r.PathValue("id")
// Роль собі не міняють: організація без жодного власника не
// відновлюється через інтерфейс.
if in.RoleID != "" && userID == p.UserID {
writeError(w, http.StatusForbidden, "forbidden", "не можна змінити роль самому собі")
return
}
if in.Username != "" {
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
if !usernameRe.MatchString(in.Username) {
writeError(w, http.StatusBadRequest, "bad_username",
"логін: 364 символи, лише латиниця, цифри, крапка, дефіс і підкреслення")
return
}
}
if in.Password != "" && len(in.Password) < 8 {
writeError(w, http.StatusBadRequest, "weak_password", "пароль щонайменше 8 символів")
return
}
if in.Username != "" || in.Email != "" || in.FullName != "" || in.Password != "" {
err := s.store.UpdateUserProfile(r.Context(), p.TenantID, userID, store.UserProfileInput{
Username: in.Username,
Email: in.Email,
FullName: in.FullName,
Password: in.Password,
})
if errors.Is(err, store.ErrSharedUser) {
writeError(w, http.StatusConflict, "shared_user",
"цей акаунт працює ще в іншій організації — профіль і пароль "+
"може змінити лише його власник")
return
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено")
return
}
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "такий логін або email уже зайнято")
return
}
s.writeStoreError(w, "оновлення профілю", err)
return
}
}
if in.RoleID != "" {
if err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID); 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 (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "users:write") {
return
}
userID := r.PathValue("id")
if p.IsUser() && userID == p.UserID {
writeError(w, http.StatusForbidden, "self_removal", "не можна видалити себе")
return
}
err := s.store.RemoveMember(r.Context(), p.TenantID, userID)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено")
return
}
if err != nil {
s.writeStoreError(w, "видалення учасника", err)
return
}
w.WriteHeader(http.StatusNoContent)
}