Форма створення хоста збирала назву, адресу, тип і групи — і не
збирала головного. Хост, доданий через 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>
205 lines
7 KiB
Go
205 lines
7 KiB
Go
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",
|
||
"логін: 3–64 символи, лише латиниця, цифри, крапка, дефіс і підкреслення")
|
||
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",
|
||
"логін: 3–64 символи, лише латиниця, цифри, крапка, дефіс і підкреслення")
|
||
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)
|
||
}
|