Логін: - 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>
219 lines
6.7 KiB
Go
219 lines
6.7 KiB
Go
// Команда 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
|
||
}
|