diff --git a/cmd/commands.go b/cmd/commands.go index 97af8f610..18c2327b2 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -249,6 +249,7 @@ var ( &databaseDisableFeature, &databaseListUsers, &databaseDeleteUser, + &databaseCreateUser, // Maintenance &databaseMaintenanceList, diff --git a/cmd/databases.go b/cmd/databases.go index 6e7e384b2..9cae820c1 100644 --- a/cmd/databases.go +++ b/cmd/databases.go @@ -197,6 +197,44 @@ var ( return nil }, } + + databaseCreateUser = cli.Command{ + Name: "database-create-user", + Category: "Addons", + ArgsUsage: "user", + Usage: "Create new database user", + Flags: []cli.Flag{ + &appFlag, + &addonFlag, + &cli.BoolFlag{Name: "read-only", Usage: "Create a user with read-only rights"}, + }, + Description: CommandDescription{ + Description: "Create new database user", + Examples: []string{ + "scalingo --app myapp --addon addon-uuid database-create-user my_user", + "scalingo --app myapp --addon addon-uuid database-create-user my_user --read-only", + }, + }.Render(), + + Action: func(c *cli.Context) error { + if c.Args().Len() != 1 { + cli.ShowCommandHelp(c, "database-create-user") + return nil + } + + currentApp := detect.CurrentApp(c) + utils.CheckForConsent(c.Context, currentApp, utils.ConsentTypeDBs) + addonName := addonUUIDFromFlags(c, currentApp, true) + + username := c.Args().First() + + err := dbUsers.CreateUser(c.Context, currentApp, addonName, username, c.Bool("read-only")) + if err != nil { + errorQuit(c.Context, err) + } + return nil + }, + } ) func parseScheduleAtFlag(flag string) (int, *time.Location, error) { diff --git a/db/users/create.go b/db/users/create.go new file mode 100644 index 000000000..823b846f2 --- /dev/null +++ b/db/users/create.go @@ -0,0 +1,110 @@ +package users + +import ( + "context" + "fmt" + "os" + + "golang.org/x/term" + + "github.com/Scalingo/cli/config" + "github.com/Scalingo/cli/io" + "github.com/Scalingo/go-scalingo/v6" + scErrors "github.com/Scalingo/go-utils/errors/v2" + "github.com/Scalingo/gopassword" +) + +func CreateUser(ctx context.Context, app, addonUUID, username string, readonly bool) error { + isSupported, err := isDatabaseHandlesUserManagement(ctx, app, addonUUID) + if err != nil { + return scErrors.Wrap(ctx, err, "get user management information") + } + + if !isSupported { + io.Error(ErrDatabaseNotSupportUserManagement) + return nil + } + + if usernameValidation, ok := isUsernameValid(username); !ok { + io.Error(usernameValidation) + return nil + } + + password, confirmedPassword, err := askForPassword(ctx) + if err != nil { + return scErrors.Wrap(ctx, err, "ask for password") + } + + passwordValidation, ok := isPasswordValid(password, confirmedPassword) + if !ok && password != "" { + io.Error(passwordValidation) + return nil + } + + isPasswordGenerated := false + if password == "" { + isPasswordGenerated = true + password = gopassword.Generate(64) + confirmedPassword = password + } + + c, err := config.ScalingoClient(ctx) + if err != nil { + return scErrors.Wrap(ctx, err, "get Scalingo client") + } + + user := scalingo.DatabaseCreateUserParam{ + DatabaseID: addonUUID, + Name: username, + Password: password, + PasswordConfirmation: confirmedPassword, + ReadOnly: readonly, + } + databaseUsers, err := c.DatabaseCreateUser(ctx, app, addonUUID, user) + if err != nil { + return scErrors.Wrap(ctx, err, "create the given database user") + } + + if isPasswordGenerated { + fmt.Printf("User \"%s\" created with password \"%s\".\n", databaseUsers.Name, password) + return nil + } + + fmt.Printf("User \"%s\" created.\n", databaseUsers.Name) + return nil +} + +func askForPassword(ctx context.Context) (string, string, error) { + fmt.Printf("Password: (Will be generated if left empty) ") + + bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", "", scErrors.Wrap(ctx, err, "read password") + } + + fmt.Printf("\nPassword Confirmation: ") + byteConfirmedPassword, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", "", scErrors.Wrap(ctx, err, "read password confirmation") + } + fmt.Printf("\n") + + return string(bytePassword), string(byteConfirmedPassword), nil +} + +func isPasswordValid(password, confirmedPassword string) (string, bool) { + if password != confirmedPassword { + return "Password confirmation don't watch", false + } + if len(password) < 8 || len(password) > 64 { + return "Password must be between 8 and 64 characters", false + } + return "", true +} + +func isUsernameValid(username string) (string, bool) { + if len(username) < 6 || len(username) > 32 { + return "name must be between 6 and 32 characters", false + } + return "", true +} diff --git a/go.mod b/go.mod index 8e53c43d1..1ef7ff90f 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/Scalingo/go-utils/errors/v2 v2.3.0 github.com/Scalingo/go-utils/logger v1.2.0 github.com/Scalingo/go-utils/retry v1.1.1 + github.com/Scalingo/gopassword v1.0.2 github.com/briandowns/spinner v1.23.0 github.com/cheggaaa/pb/v3 v3.1.4 github.com/dustin/go-humanize v1.0.1 diff --git a/go.sum b/go.sum index e7946cd45..c440ef9e9 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/Scalingo/go-utils/logger v1.2.0 h1:E3jtaoRxpIsFcZu/jsvWew8ttUAwKUYQuf github.com/Scalingo/go-utils/logger v1.2.0/go.mod h1:JArjD1gHdB/vwnlcVG7rYxuIY0tk8/VG4MtirnRwn8k= github.com/Scalingo/go-utils/retry v1.1.1 h1:zc5HbXbBzf0fo7zMvhEMvx75qaL2FH7SxDhCoBfS7jE= github.com/Scalingo/go-utils/retry v1.1.1/go.mod h1:za1k9sUU7fSQEFTv3c4Y8kG/8ybsq6C77sOo3ANEeN4= +github.com/Scalingo/gopassword v1.0.2 h1:wcKt8twrnDgYi4W9ep54jUfNNUyTmUb/1uS8F5+iUvQ= +github.com/Scalingo/gopassword v1.0.2/go.mod h1:qthHL75azoNpGlQlVHIaljbONYubRatIHJW5VYVFOjQ= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= diff --git a/vendor/github.com/Scalingo/gopassword/CHANGELOG.md b/vendor/github.com/Scalingo/gopassword/CHANGELOG.md new file mode 100644 index 000000000..ed2123054 --- /dev/null +++ b/vendor/github.com/Scalingo/gopassword/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## To be Released + +## 1.0.2 + +* chore(go): use go 1.17 +* Bump github.com/stretchr/testify from 1.7.0 to 1.7.1 + +## 1.0.1 + +* Bump github.com/stretchr/testify from 1.6.1 to 1.7.0 + +## 1.0.0 + +* Add support for Go Modules + +## Mon 13 Apr 2015 - Leo Unbekandt + +* No `-` at the begining or end of the password diff --git a/vendor/github.com/Scalingo/gopassword/README.md b/vendor/github.com/Scalingo/gopassword/README.md new file mode 100644 index 000000000..1b342f80f --- /dev/null +++ b/vendor/github.com/Scalingo/gopassword/README.md @@ -0,0 +1,28 @@ +# Go Password v1.0.2 + +Simple password generator in Go. Use `crypto/rand` + +```go +// Passowrd of 20 characters +gopassword.Generate() + +// Password of 42 characters +gopassword.Generate(42) +``` + +## Release a New Version + +Bump new version number in `CHANGELOG.md` and `README.md`. + +Commit, tag and create a new release: + +```sh +git add CHANGELOG.md README.md +git commit -m "Bump v1.0.2" +git tag v1.0.2 +git push origin master +git push --tags +hub release create v1.0.2 +``` + +The title of the release should be the version number and the text of the release is the same as the changelog. diff --git a/vendor/github.com/Scalingo/gopassword/gopassword.go b/vendor/github.com/Scalingo/gopassword/gopassword.go new file mode 100644 index 000000000..7a893477d --- /dev/null +++ b/vendor/github.com/Scalingo/gopassword/gopassword.go @@ -0,0 +1,37 @@ +package gopassword + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "strings" +) + +func Generate(n ...int) string { + length := 20 + if len(n) > 0 { + length = n[0] + } + + // With base64 encoding we need 3 random bytes to + // have 4 random characters + randSize := 3 * (length/4 + 1) + randBytes := make([]byte, randSize) + rand.Read(randBytes) + + // Encode them in base64 + randString := base64.StdEncoding.EncodeToString(randBytes) + + password := randString[:length] + password = strings.Replace(password, "+", "_", -1) + password = strings.Replace(password, "/", "-", -1) + + if password[0] == '-' { + password = fmt.Sprintf("_%s", password[1:]) + } + if password[length-1] == '-' { + password = fmt.Sprintf("%s_", password[:length-1]) + } + + return password +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 4046371ed..6fddeb815 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -33,7 +33,7 @@ github.com/ProtonMail/go-crypto/openpgp/internal/ecc github.com/ProtonMail/go-crypto/openpgp/internal/encoding github.com/ProtonMail/go-crypto/openpgp/packet github.com/ProtonMail/go-crypto/openpgp/s2k -# github.com/Scalingo/go-scalingo/v6 v6.7.3 +# github.com/Scalingo/go-scalingo/v6 v6.7.3 => ../go-scalingo ## explicit; go 1.20 github.com/Scalingo/go-scalingo/v6 github.com/Scalingo/go-scalingo/v6/billing @@ -50,6 +50,9 @@ github.com/Scalingo/go-utils/logger # github.com/Scalingo/go-utils/retry v1.1.1 ## explicit; go 1.17 github.com/Scalingo/go-utils/retry +# github.com/Scalingo/gopassword v1.0.2 +## explicit; go 1.17 +github.com/Scalingo/gopassword # github.com/VividCortex/ewma v1.2.0 ## explicit; go 1.12 github.com/VividCortex/ewma @@ -337,3 +340,4 @@ gopkg.in/warnings.v0 # gopkg.in/yaml.v3 v3.0.1 ## explicit gopkg.in/yaml.v3 +# github.com/Scalingo/go-scalingo/v6 => ../go-scalingo