Create Bulk Users from CSV (#3372)
* Create Bulk Users * WIP: Adding a test for bulk user import * adding a user bulk create test * Fixing description, removing password required, and adding more test cases * Fixing description, removing password required, and adding more test cases * Fixed all comments and added Random Password Generator * returning an error in generateRandomPassword * Using 2 loops to create user list and then create the actual users * Adding a bulk user delete * fixing a mistake in temp csv * fixed lints and removed yamlFlag
This commit is contained in:
+173
-1
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/sethvargo/go-password/password"
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
)
|
||||
@@ -22,6 +24,7 @@ const (
|
||||
nameFlagName = "name"
|
||||
ssoFlagName = "sso"
|
||||
apiOnlyFlagName = "api-only"
|
||||
csvFlagName = "csv"
|
||||
)
|
||||
|
||||
func userCommand() *cli.Command {
|
||||
@@ -31,6 +34,8 @@ func userCommand() *cli.Command {
|
||||
Subcommands: []*cli.Command{
|
||||
createUserCommand(),
|
||||
deleteUserCommand(),
|
||||
createBulkUsersCommand(),
|
||||
deleteBulkUsersCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -101,7 +106,7 @@ func createUserCommand() *cli.Command {
|
||||
globalRole = ptr.String(fleet.RoleObserver)
|
||||
} else if globalRoleString != "" {
|
||||
if !fleet.ValidGlobalRole(globalRoleString) {
|
||||
return fmt.Errorf("'%s' is not a valid team role", globalRoleString)
|
||||
return fmt.Errorf("'%s' is not a valid global role", globalRoleString)
|
||||
}
|
||||
globalRole = ptr.String(globalRoleString)
|
||||
} else {
|
||||
@@ -174,6 +179,121 @@ func createUserCommand() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func createBulkUsersCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "create-users",
|
||||
Usage: "Create bulk users",
|
||||
UsageText: `This command will create a set of users in Fleet by importing a CSV file. Expected columns are: Name,Email,SSO,API Only,Global Role,Teams. Created Users by default get random password and Observer Role.`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: csvFlagName,
|
||||
Usage: "csv file with all the users (required)",
|
||||
Required: true,
|
||||
},
|
||||
configFlag(),
|
||||
contextFlag(),
|
||||
debugFlag(),
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
client, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
csvFilePath := c.String(csvFlagName)
|
||||
|
||||
csvFile, err := os.Open(csvFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer csvFile.Close()
|
||||
csvLines, err := csv.NewReader(csvFile).ReadAll()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := []fleet.UserPayload{}
|
||||
for _, record := range csvLines[1:] {
|
||||
name := record[0]
|
||||
email := record[1]
|
||||
password, passErr := generateRandomPassword()
|
||||
sso, ssoErr := strconv.ParseBool(record[2])
|
||||
apiOnly, apiErr := strconv.ParseBool(record[3])
|
||||
globalRoleString := record[4]
|
||||
teamStrings := strings.Split(record[5], " ")
|
||||
if ssoErr != nil {
|
||||
return fmt.Errorf("SSO is not a vailed Boolean value: %w", err)
|
||||
}
|
||||
if apiErr != nil {
|
||||
return fmt.Errorf("API Only is not a vailed Boolean value: %w", err)
|
||||
}
|
||||
if passErr != nil {
|
||||
return fmt.Errorf("not able to generate a random password: %w", err)
|
||||
}
|
||||
|
||||
var globalRole *string
|
||||
var teams []fleet.UserTeam
|
||||
|
||||
if globalRoleString != "" && len(teamStrings) > 0 && teamStrings[0] != "" {
|
||||
return errors.New("Users may not have global_role and teams.")
|
||||
} else if globalRoleString == "" && (len(teamStrings) == 0 || teamStrings[0] == "") {
|
||||
globalRole = ptr.String(fleet.RoleObserver)
|
||||
} else if globalRoleString != "" {
|
||||
if !fleet.ValidGlobalRole(globalRoleString) {
|
||||
return fmt.Errorf("'%s' is not a valid team role", globalRoleString)
|
||||
}
|
||||
globalRole = ptr.String(globalRoleString)
|
||||
} else {
|
||||
for _, t := range teamStrings {
|
||||
parts := strings.Split(t, ":")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("Unable to parse '%s' as team_id:role", t)
|
||||
}
|
||||
teamID, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to parse team_id: %w", err)
|
||||
}
|
||||
if !fleet.ValidTeamRole(parts[1]) {
|
||||
return fmt.Errorf("'%s' is not a valid team role", parts[1])
|
||||
}
|
||||
|
||||
teams = append(teams, fleet.UserTeam{Team: fleet.Team{ID: uint(teamID)}, Role: parts[1]})
|
||||
}
|
||||
}
|
||||
|
||||
if sso && len(password) > 0 {
|
||||
password = ""
|
||||
}
|
||||
force_reset := !sso
|
||||
users = append(users, fleet.UserPayload{
|
||||
Password: &password,
|
||||
Email: &email,
|
||||
Name: &name,
|
||||
SSOEnabled: &sso,
|
||||
AdminForcedPasswordReset: &force_reset,
|
||||
APIOnly: &apiOnly,
|
||||
GlobalRole: globalRole,
|
||||
Teams: &teams,
|
||||
})
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
err = client.CreateUser(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create user: %w", err)
|
||||
}
|
||||
if *user.SSOEnabled {
|
||||
fmt.Printf("Email: %v SSO: %v\n", *user.Email, *user.SSOEnabled)
|
||||
} else {
|
||||
fmt.Printf("Email: %v Generated password: %v\n", *user.Email, *user.Password)
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deleteUserCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "delete",
|
||||
@@ -201,3 +321,55 @@ func deleteUserCommand() *cli.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBulkUsersCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "delete-users",
|
||||
Usage: "Delete a list of user",
|
||||
UsageText: `This command will delete a list of users by importing a CSV file containing a list of emails. Expected columns are:Email`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: csvFlagName,
|
||||
Usage: "csv file with all the users (required)",
|
||||
Required: true,
|
||||
},
|
||||
configFlag(),
|
||||
contextFlag(),
|
||||
debugFlag(),
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
client, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
csvFilePath := c.String(csvFlagName)
|
||||
|
||||
csvFile, err := os.Open(csvFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer csvFile.Close()
|
||||
csvLines, err := csv.NewReader(csvFile).ReadAll()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range csvLines[1:] {
|
||||
email := user[0]
|
||||
if err := client.DeleteUser(email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
func generateRandomPassword() (string, error) {
|
||||
password, err := password.Generate(20, 2, 2, false, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"io/ioutil"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
@@ -96,3 +102,87 @@ func TestUserCreateForcePasswordReset(t *testing.T) {
|
||||
require.True(t, ds.NewUserFuncInvoked)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTmpCsv(t *testing.T, contents string) string {
|
||||
tmpFile, err := ioutil.TempFile(t.TempDir(), "*.csv")
|
||||
require.NoError(t, err)
|
||||
_, err = tmpFile.WriteString(contents)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tmpFile.Close())
|
||||
return tmpFile.Name()
|
||||
}
|
||||
|
||||
func TestCreateBulkUsers(t *testing.T) {
|
||||
_, ds := runServerWithMockedDS(t)
|
||||
ds.InviteByEmailFunc = func(ctx context.Context, email string) (*fleet.Invite, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
csvFile := writeTmpCsv(t,
|
||||
`Name,Email,SSO,API Only,Global Role,Teams
|
||||
user11,user11@example.com,false,false,maintainer,
|
||||
user12,user12@example.com,false,false,,
|
||||
user13,user13@example.com,true,false,admin,
|
||||
user14,user14@example.com,false,false,,2:maintainer
|
||||
user15,user15@example.com,false,false,,1:admin
|
||||
user16,user16@example.com,false,false,,1:admin 2:maintainer`)
|
||||
|
||||
expectedText := `{"kind":"user_roles","apiVersion":"v1","spec":{"roles":{"admin1@example.com":{"global_role":"admin","teams":null},"user11@example.com":{"global_role":"maintainer","teams":null},"user12@example.com":{"global_role":"observer","teams":null},"user13@example.com":{"global_role":"admin","teams":null},"user14@example.com":{"global_role":null,"teams":[{"team":"","role":"maintainer"}]},"user15@example.com":{"global_role":null,"teams":[{"team":"","role":"admin"}]},"user16@example.com":{"global_role":null,"teams":[{"team":"","role":"admin"},{"team":"","role":"maintainer"}]},"user1@example.com":{"global_role":"observer","teams":null},"user2@example.com":{"global_role":"observer","teams":null}}}}
|
||||
`
|
||||
|
||||
assert.Equal(t, "", runAppForTest(t, []string{"user", "create-users", "--csv", csvFile}))
|
||||
assert.Equal(t, expectedText, runAppForTest(t, []string{"get", "user_roles", "--json"}))
|
||||
|
||||
}
|
||||
|
||||
func TestDeleteBulkUsers(t *testing.T) {
|
||||
_, ds := runServerWithMockedDS(t)
|
||||
csvFilePath := writeTmpCsv(t,
|
||||
`Email
|
||||
user11@example.com
|
||||
user12@example.com
|
||||
user13@example.com`)
|
||||
|
||||
csvFile, err := os.Open(csvFilePath)
|
||||
require.NoError(t, err)
|
||||
defer csvFile.Close()
|
||||
|
||||
csvLines, err := csv.NewReader(csvFile).ReadAll()
|
||||
require.NoError(t, err)
|
||||
|
||||
users := []fleet.User{}
|
||||
deletedUserIds := []uint{}
|
||||
for _, user := range csvLines[1:] {
|
||||
email := user[0]
|
||||
name := strings.Split(email, "@")[0]
|
||||
|
||||
randId, err := rand.Int(rand.Reader, big.NewInt(1000))
|
||||
require.NoError(t, err)
|
||||
id := uint(randId.Int64())
|
||||
|
||||
users = append(users, fleet.User{
|
||||
Name: name,
|
||||
Email: email,
|
||||
ID: id,
|
||||
})
|
||||
deletedUserIds = append(deletedUserIds, id)
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
ds.UserByEmailFunc = func(ctx context.Context, email string) (*fleet.User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
}
|
||||
deletedUser := uint(0)
|
||||
|
||||
ds.DeleteUserFunc = func(ctx context.Context, id uint) error {
|
||||
deletedUser = id
|
||||
return nil
|
||||
}
|
||||
|
||||
assert.Equal(t, "", runAppForTest(t, []string{"user", "delete-users", "--csv", csvFilePath}))
|
||||
for indx, user := range users {
|
||||
deletedUser = deletedUserIds[indx]
|
||||
assert.Equal(t, user.ID, deletedUser)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user