Логін: - 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>
148 lines
4.8 KiB
Go
148 lines
4.8 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)
|
||
}
|
||
|
||
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"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
userID := r.PathValue("id")
|
||
// Себе понизити не можна: інакше єдиний адміністратор тенанта
|
||
// одним кліком лишає організацію без керування.
|
||
if p.IsUser() && userID == p.UserID {
|
||
writeError(w, http.StatusForbidden, "self_demotion", "не можна змінити власну роль")
|
||
return
|
||
}
|
||
|
||
if err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID); err != nil {
|
||
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)
|
||
}
|