За цілий день активної роботи в журналі не з'явилось нічого: цих доменів у ньому просто не було. Проєкт це визнавав сам, у AuditBlindSpots(). Тепер пишуться: правила алертів (створення/зміна/видалення й окремо вимкнення-увімкнення), канали, драбини ескалації, правила відповідності; додавання людини в кабінет, зміна ролі, вилучення, правка профілю й скидання пароля. Два рішення про зміст запису: * вимкнення видно з НАЗВИ дії (alr.rule.disable), а не з різниці подробиць — питання «хто вимкнув правило, за яким приходив алерт» має відповідатись переліком, а не порівнянням; * config каналу не їде в запис ВЗАГАЛІ — там не лише токен бота, а й адреса вебхука (доступ на запис у чужий чат) і заголовок Authorization. Замість нього прапорець secret_changed. Мапи, групи й шаблони свідомо лишились у сліпих зонах: їхня правка видима в самому об'єкті. Туди ж додано дві нові чесні межі — тригери від шаблонів пишуться повз обробник, і пробне повідомлення в канал. Прогнано проти справжньої бази: секрет каналу й пароль учасника в подробиці не потрапляють. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
9.7 KiB
Go
245 lines
9.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
|
||
}
|
||
// Роль пишемо ключем, а не ідентифікатором: ключ прийшов у запиті,
|
||
// і саме він потім стоятиме в питанні «хто дав цій людині 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",
|
||
"логін: 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 != "" {
|
||
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)
|
||
}
|