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:
Ahmed Elshaer
2022-06-22 13:34:58 -03:00
committed by GitHub
parent 1faa3c47b2
commit a9f48ff561
4 changed files with 266 additions and 1 deletions
+173 -1
View File
@@ -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
}
+90
View File
@@ -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)
}
}