Netpulse_SasS/server/internal/httpapi/users.go
byrsapty f6df538020 Аудит: закрито дві найдорожчі сліпі зони — алерти й склад команди
За цілий день активної роботи в журналі не з'явилось нічого: цих
доменів у ньому просто не було. Проєкт це визнавав сам, у
AuditBlindSpots().

Тепер пишуться: правила алертів (створення/зміна/видалення й окремо
вимкнення-увімкнення), канали, драбини ескалації, правила
відповідності; додавання людини в кабінет, зміна ролі, вилучення,
правка профілю й скидання пароля.

Два рішення про зміст запису:
* вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці
  подробиць — питання «хто вимкнув правило, за яким приходив алерт»
  має відповідатись переліком, а не порівнянням;
* config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й
  адреса вебхука (доступ на запис у чужий чат) і заголовок
  Authorization. Замість нього прапорець secret_changed.

Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка
видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери
від шаблонів пишуться повз обробник, і пробне повідомлення в канал.

Прогнано проти справжньої бази: секрет каналу й пароль учасника в
подробиці не потрапляють.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:21 +03:00

245 lines
9.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
}
// Роль пишемо ключем, а не ідентифікатором: ключ прийшов у запиті,
// і саме він потім стоятиме в питанні «хто дав цій людині admin».
// Пароля в подробицях немає й бути не може — ні тут, ні в зміні
// профілю нижче.
s.audit(r, p, store.AuditActionMemberAdd, store.AuditObjectMember, u.ID,
map[string]any{"username": u.Username, "role_key": in.RoleKey})
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 != "" {
profile := store.UserProfileInput{
Username: in.Username,
Email: in.Email,
FullName: in.FullName,
Password: in.Password,
}
username, err := s.store.UpdateUserProfile(r.Context(), p.TenantID, userID, profile)
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
}
// Скидання пароля перебиває решту полів: це найдорожчий факт
// запиту, і його має бути видно з назви дії, а не з переліку
// змінених полів усередині подробиць.
meta := memberProfileAuditMeta(profile, username)
if profile.Password != "" {
s.audit(r, p, store.AuditActionMemberPassword, store.AuditObjectMember, userID, meta)
} else {
s.audit(r, p, store.AuditActionMemberProfile, store.AuditObjectMember, userID, meta)
}
}
if in.RoleID != "" {
before, after, err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "учасника або роль не знайдено")
return
}
s.writeStoreError(w, "зміна ролі", err)
return
}
// Роль, яка не змінилась, у журнал не йде. Форма команди шле
// role_id завжди — навіть коли правили лише ім'я, — і рядок
// «змінив роль з Адміністратора на Адміністратора» був би не
// подією, а шумом рівно в тому переліку, який відкривають, щоб
// подію знайти.
//
// Окремий запис, а не поле в правці профілю вище, хоч форма й
// одна: це різні події для різних читачів. Профіль питають про
// «хто перейменував», роль — про «звідки в цієї людини такий
// доступ», і зводити їх в один рядок означало б, що друге
// питання доводиться шукати всередині відповіді на перше.
if before.RoleID != after.RoleID {
s.audit(r, p, store.AuditActionMemberRole, store.AuditObjectMember, userID,
memberRoleAuditMeta(before, after))
}
}
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
}
gone, 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
}
// Роль на момент вилучення — не прикраса: рядок membership зникає
// разом із нею, а питання «що ця людина тут могла» ставлять уже
// після того, як її прибрали.
s.audit(r, p, store.AuditActionMemberRemove, store.AuditObjectMember, gone.UserID,
map[string]any{
"username": gone.Username,
"role_key": gone.RoleKey, "role_name": gone.RoleName,
})
w.WriteHeader(http.StatusNoContent)
}