// Команда 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 тенанта") newTenant = flag.String("create-tenant", "", "створити тенанта з таким іменем, якщо його ще немає") 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, *newTenant) 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 } // resolveTenant знаходить кабінет або створює перший. // // Свіжа інсталяція не має жодного тенанта, а користувач без кабінету // нікуди не належить. Виходить те саме коло, заради розриву якого існує // ця команда, лише рівнем вище: щоб завести власника, потрібен кабінет, // а щоб завести кабінет — панель, у яку ще нікому зайти. // // Створення за окремим прапорцем, а не мовчки: друкарська помилка в // -tenant інакше плодила б порожні кабінети замість зрозумілої відмови. func resolveTenant(ctx context.Context, conn *pgx.Conn, ref, createName 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 id, name, err } if createName == "" { return "", "", fmt.Errorf( "тенанта %q не знайдено; для першого запуску додайте -create-tenant \"Назва\"", ref) } if err := conn.QueryRow(ctx, ` INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text, name `, ref, createName).Scan(&id, &name); err != nil { return "", "", fmt.Errorf("створення тенанта: %w", err) } fmt.Printf("створено кабінет %q (%s)\n", name, ref) return id, name, nil } 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 }