Netpulse_SasS/server/cmd/netpulse-user/main.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- 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>
2026-08-24 02:00:04 +03:00

219 lines
6.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.

// Команда netpulse-user — заведення першого власника й керування
// користувачами з командного рядка.
//
// Потрібна через «проблему курки і яйця»: щоб зайти в панель, треба
// бути користувачем, а щоб створити користувача через API — треба
// зайти. Цей інструмент розриває коло, і він же лишається способом
// відновити доступ, коли єдиний адміністратор загубив пароль.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"syscall"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/auth"
"golang.org/x/term"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "netpulse-user:", err)
os.Exit(1)
}
}
func run() error {
var (
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
tenant = flag.String("tenant", "", "slug або uuid тенанта")
login = flag.String("login", "", "логін користувача")
email = flag.String("email", "", "email (необовʼязково — для сповіщень)")
fullName = flag.String("name", "", "повне ім'я")
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
list = flag.Bool("list", false, "показати користувачів тенанта")
reset = flag.Bool("reset-password", false, "змінити пароль наявного користувача")
)
flag.Parse()
if *dsn == "" {
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
}
if *tenant == "" {
return errors.New("не вказано -tenant")
}
ctx := context.Background()
conn, err := pgx.Connect(ctx, *dsn)
if err != nil {
return fmt.Errorf("підключення: %w", err)
}
defer conn.Close(ctx)
tenantID, tenantName, err := resolveTenant(ctx, conn, *tenant)
if err != nil {
return err
}
if *list {
return listUsers(ctx, conn, tenantID, tenantName)
}
if *login == "" {
return errors.New("не вказано -login")
}
pw := *password
if pw == "" {
pw, err = askPassword()
if err != nil {
return err
}
}
if len(pw) < 8 {
return errors.New("пароль коротший за 8 символів")
}
hash, err := auth.HashPassword(pw)
if err != nil {
return err
}
if *reset {
tag, err := conn.Exec(ctx, `
UPDATE core.users SET password_hash = $2, updated_at = now()
WHERE username = $1 AND deleted_at IS NULL
`, *login, hash)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("користувача %s не знайдено", *login)
}
// Зміна пароля адміністратором — це реакція на втрату доступу
// або на компрометацію. В обох випадках старі сесії мають померти.
if _, err := conn.Exec(ctx, `
UPDATE core.sessions SET revoked_at = now()
WHERE user_id = (SELECT id FROM core.users WHERE email = $1)
AND revoked_at IS NULL
`, *email); err != nil {
return err
}
fmt.Printf("пароль змінено, усі сесії %s відкликано\n", *email)
return nil
}
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var userID string
if err := tx.QueryRow(ctx, `
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
ON CONFLICT (username) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name),
updated_at = now()
RETURNING id::text
`, *login, *email, hash, *fullName).Scan(&userID); err != nil {
return fmt.Errorf("користувач: %w", err)
}
var roleID string
if err := tx.QueryRow(ctx, `
SELECT id::text FROM core.roles
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY tenant_id NULLS LAST LIMIT 1
`, *role, tenantID).Scan(&roleID); err != nil {
return fmt.Errorf("роль %q: %w", *role, err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.memberships (tenant_id, user_id, role_id, accepted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id
`, tenantID, userID, roleID); err != nil {
return fmt.Errorf("членство: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return err
}
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
*login, userID, tenantName, *role)
return nil
}
func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name string, err error) {
err = conn.QueryRow(ctx, `
SELECT id::text, name FROM core.tenants
WHERE (slug::text = $1 OR id::text = $1) AND deleted_at IS NULL
`, ref).Scan(&id, &name)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("тенанта %q не знайдено", ref)
}
return id, name, err
}
func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error {
rows, err := conn.Query(ctx, `
SELECT u.username::text, COALESCE(u.full_name,''), r.key::text,
COALESCE(u.last_login_at::text, '—')
FROM core.memberships m
JOIN core.users u ON u.id = m.user_id
JOIN core.roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
ORDER BY u.username
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
fmt.Printf("Користувачі «%s»:\n", tenantName)
n := 0
for rows.Next() {
var login, name, role, last string
if err := rows.Scan(&login, &name, &role, &last); err != nil {
return err
}
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", login, role, name, last)
n++
}
if n == 0 {
fmt.Println(" (порожньо)")
}
return rows.Err()
}
// askPassword читає пароль без відлуння в терміналі.
func askPassword() (string, error) {
fmt.Print("Пароль: ")
if term.IsTerminal(int(syscall.Stdin)) {
b, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
return string(b), nil
}
// Не термінал (скрипт, конвеєр) — читаємо рядок як є.
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}