package commands import ( "context" "errors" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "unicode" "github.com/fatih/color" "github.com/urfave/cli/v2" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" ) func initConflictCfg(cmd *utils.ContextCommandLine) (*setting.Cfg, tracing.Tracer, featuremgmt.FeatureToggles, error) { configOptions := strings.Split(cmd.String("configOverrides"), " ") configOptions = append(configOptions, cmd.Args().Slice()...) cfg, err := setting.NewCfgFromArgs(setting.CommandLineArgs{ Config: cmd.ConfigFile(), HomePath: cmd.HomePath(), Args: append(configOptions, "cfg:log.level=error"), // tailing arguments have precedence over the options string }) if err != nil { return nil, nil, nil, err } features, err := featuremgmt.ProvideManagerService(cfg) if err != nil { return nil, nil, nil, err } tracingCfg, err := tracing.ProvideTracingConfig(cfg) if err != nil { return nil, nil, nil, fmt.Errorf("%v: %w", "failed to initialize tracer config", err) } tracer, err := tracing.ProvideService(tracingCfg) if err != nil { return nil, nil, nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) } return cfg, tracer, features, err } func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx *cli.Context) (*ConflictResolver, error) { cfg, tracer, features, err := initConflictCfg(cmd) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to load configuration", err) } s, replstore, err := getSqlStore(cfg, tracer, features) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get to sql", err) } conflicts, err := GetUsersWithConflictingEmailsOrLogins(ctx, s) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) } quotaService := quotaimpl.ProvideService(s, cfg) userService, err := userimpl.ProvideService(s, nil, cfg, nil, nil, tracer, quotaService, supportbundlestest.NewFakeBundleService()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get user service", err) } routing := routing.ProvideRegister() if err != nil { return nil, fmt.Errorf("%v: %w", "failed to initialize tracer config", err) } if err != nil { return nil, fmt.Errorf("%v: %w", "failed to initialize tracer service", err) } acService, err := acimpl.ProvideService(cfg, replstore, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get access control", err) } resolver := ConflictResolver{Users: conflicts, Store: s, userService: userService, ac: acService} resolver.BuildConflictBlocks(conflicts, f) return &resolver, nil } func getSqlStore(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, *sqlstore.ReplStore, error) { bus := bus.ProvideBus(tracer) ss, err := sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) if err != nil { return nil, nil, err } replStore, err := sqlstore.ProvideServiceWithReadReplica(ss, cfg, features, &migrations.OSSMigrations{}, bus, tracer) return ss, replStore, err } func runListConflictUsers() func(context *cli.Context) error { return func(context *cli.Context) error { cmd := &utils.ContextCommandLine{Context: context} whiteBold := color.New(color.FgWhite).Add(color.Bold) r, err := initializeConflictResolver(cmd, whiteBold.Sprintf, context) if err != nil { return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) } if len(r.Users) < 1 { logger.Info(color.GreenString("No Conflicting users found.\n\n")) return nil } logger.Infof("\n\nShowing conflicts\n\n") logger.Infof(r.ToStringPresentation()) logger.Infof("\n") if len(r.DiscardedBlocks) != 0 { r.logDiscardedUsers() } return nil } } func runGenerateConflictUsersFile() func(context *cli.Context) error { return func(context *cli.Context) error { cmd := &utils.ContextCommandLine{Context: context} r, err := initializeConflictResolver(cmd, fmt.Sprintf, context) if err != nil { return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) } if len(r.Users) < 1 { logger.Info(color.GreenString("No Conflicting users found.\n\n")) return nil } tmpFile, err := generateConflictUsersFile(r) if err != nil { return fmt.Errorf("generating file return error: %w", err) } logger.Infof("\n\ngenerated file\n") logger.Infof("%s\n\n", tmpFile.Name()) logger.Infof("once the file is edited and resolved conflicts, you can either validate or ingest the file\n\n") if len(r.DiscardedBlocks) != 0 { r.logDiscardedUsers() } return nil } } func runValidateConflictUsersFile() func(context *cli.Context) error { return func(context *cli.Context) error { cmd := &utils.ContextCommandLine{Context: context} r, err := initializeConflictResolver(cmd, fmt.Sprintf, context) if err != nil { return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) } // read in the file to validate // read in the file to ingest arg := cmd.Args().First() if arg == "" { return fmt.Errorf("please specify a absolute path to file to read from") } b, err := os.ReadFile(filepath.Clean(arg)) if err != nil { logger.Error(color.RedString("validation failed with an error")) return fmt.Errorf("could not read file with error %s", err) } validErr := getValidConflictUsers(r, b) if validErr != nil { logger.Error(color.RedString("validation failed with an error")) return fmt.Errorf("could not validate file with error:\n%s", validErr) } logger.Info(color.GreenString("File validation complete.\n")) logger.Info("File can be used with the `ingest-file` command.\n\n") return nil } } func runIngestConflictUsersFile() func(context *cli.Context) error { return func(context *cli.Context) error { cmd := &utils.ContextCommandLine{Context: context} r, err := initializeConflictResolver(cmd, fmt.Sprintf, context) if err != nil { return fmt.Errorf("%v: %w", "failed to initialize conflict resolver", err) } // read in the file to ingest arg := cmd.Args().First() if arg == "" { return errors.New("please specify a absolute path to file to read from") } b, err := os.ReadFile(filepath.Clean(arg)) if err != nil { return fmt.Errorf("could not read file with error %e", err) } validErr := getValidConflictUsers(r, b) if validErr != nil { return fmt.Errorf("could not validate file with error:\n%s", validErr) } // should we rebuild blocks here? // kind of a weird thing maybe? if len(r.ValidUsers) == 0 { return fmt.Errorf("no users") } r.showChanges() if !confirm("\n\nWe encourage users to create a db backup before running this command. \n Proceed with operation") { return fmt.Errorf("user cancelled") } err = r.MergeConflictingUsers(context.Context) if err != nil { return fmt.Errorf("not able to merge with %e", err) } logger.Info("\n\nconflicts resolved.\n") return nil } } func getDocumentationForFile() string { return `# Conflicts File # This file is generated by the grafana-cli command ` + color.CyanString("grafana-cli admin user-manager conflicts generate-file") + `. # # Commands: # +, keep = keep user # -, delete = delete user # # The fields conflict_email and conflict_login # indicate that we see a conflict in email and/or login with another user. # Both these fields can be true. # # There needs to be exactly one picked user per conflict block. # # The lines can be re-ordered. # # If you feel like you want to wait with a specific block, # delete all lines regarding that conflict block. # email - the user’s email # login - the user’s login/username # last_seen_at - the user’s last login # auth_module - if the user was created/signed in using an authentication provider # conflict_email - a boolean if we consider the email to be a conflict # conflict_login - a boolean if we consider the login to be a conflict # ` } func generateConflictUsersFile(r *ConflictResolver) (*os.File, error) { tmpFile, err := os.CreateTemp(os.TempDir(), "conflicting_user_*.diff") if err != nil { return nil, err } if _, err := tmpFile.WriteString(getDocumentationForFile()); err != nil { return nil, err } if _, err := tmpFile.WriteString(r.ToStringPresentation()); err != nil { return nil, err } return tmpFile, nil } func getValidConflictUsers(r *ConflictResolver, b []byte) error { newConflicts := make(ConflictingUsers, 0) // need to verify that id or email exists previouslySeenIds := map[string]bool{} previouslySeenEmails := map[string]bool{} previouslySeenLogins := map[string]bool{} for _, users := range r.Blocks { for _, u := range users { previouslySeenIds[strings.ToLower(u.ID)] = true previouslySeenEmails[strings.ToLower(u.Email)] = true previouslySeenLogins[strings.ToLower(u.Login)] = true } } // tested in https://regex101.com/r/una3zC/1 diffPattern := `^[+-]` // compiling since in a loop matchingExpression, err := regexp.Compile(diffPattern) if err != nil { return fmt.Errorf("unable to compile regex %s: %w", diffPattern, err) } counterKeepUsersForBlock := map[string]int{} counterDeleteUsersForBlock := map[string]int{} currentBlock := "" for rowNumber, row := range strings.Split(string(b), "\n") { // end of file if row == "" { break } // if the row starts with a #, it is a comment if row[0] == '#' { continue } entryRow := matchingExpression.MatchString(row) // not an entry row -> is a conflict block row if !entryRow { // check for malformed row // rows should be of the form // conflict: // or // + id: // - id: if (row[0] != '-') && (row[0] != '+') && (row[0] != 'c') { return fmt.Errorf("invalid start character (expected '+,-') found %c for row number %d", row[0], rowNumber+1) } // is a conflict block row // conflict: hej currentBlock = row continue } // need to track how many keep users we have for a block if _, ok := counterKeepUsersForBlock[currentBlock]; !ok { counterKeepUsersForBlock[currentBlock] = 0 } if _, ok := counterDeleteUsersForBlock[currentBlock]; !ok { counterDeleteUsersForBlock[currentBlock] = 0 } if row[0] == '+' { counterKeepUsersForBlock[currentBlock] += 1 } if row[0] == '-' { counterDeleteUsersForBlock[currentBlock] += 1 } newUser := &ConflictingUser{} err := newUser.Marshal(row) if err != nil { return fmt.Errorf("could not parse the content of the file with error %e", err) } if newUser.ConflictEmail != "" && !previouslySeenEmails[strings.ToLower(newUser.Email)] { return fmt.Errorf("not valid email: %s, email not seen in previous conflicts", newUser.Email) } if newUser.ConflictLogin != "" && !previouslySeenLogins[strings.ToLower(newUser.Login)] { return fmt.Errorf("not valid login: %s, login not seen in previous conflicts", newUser.Login) } // valid entry newConflicts = append(newConflicts, *newUser) } for block, count := range counterKeepUsersForBlock { // check if we only have one addition for each block if count != 1 { return fmt.Errorf("invalid number of users to keep, expected 1, got %d for block: %s", count, block) } } for block, count := range counterDeleteUsersForBlock { // check if we have at least one deletion for each block if count < 1 { return fmt.Errorf("invalid number of users to delete, should be at least 1, got %d for block %s", count, block) } } r.ValidUsers = newConflicts r.BuildConflictBlocks(newConflicts, fmt.Sprintf) return nil } func (r *ConflictResolver) MergeConflictingUsers(ctx context.Context) error { for block, users := range r.Blocks { if len(users) < 2 { return fmt.Errorf("not enough users to perform merge, found %d for id %s, should be at least 2", len(users), block) } var intoUser user.User var intoUserId int64 var fromUserIds []int64 // creating a session for each block of users // we want to rollback incase something happens during update / delete if err := r.Store.InTransaction(ctx, func(ctx context.Context) error { for _, u := range users { if u.Direction == "+" { id, err := strconv.ParseInt(u.ID, 10, 64) if err != nil { return fmt.Errorf("could not convert id in +") } intoUserId = id } else if u.Direction == "-" { id, err := strconv.ParseInt(u.ID, 10, 64) if err != nil { return fmt.Errorf("could not convert id in -") } fromUserIds = append(fromUserIds, id) } } if _, err := r.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: intoUserId}); err != nil { return fmt.Errorf("could not find intoUser: %w", err) } for _, fromUserId := range fromUserIds { _, err := r.userService.GetByID(ctx, &user.GetUserByIDQuery{ID: fromUserId}) if err != nil && errors.Is(err, user.ErrUserNotFound) { fmt.Printf("user with id %d does not exist, skipping\n", fromUserId) } if err != nil { return fmt.Errorf("could not find fromUser: %w", err) } // delete the user delErr := r.userService.Delete(ctx, &user.DeleteUserCommand{UserID: fromUserId}) if delErr != nil { return fmt.Errorf("error during deletion of user: %w", delErr) } delACErr := r.ac.DeleteUserPermissions(ctx, 0, fromUserId) if delACErr != nil { return fmt.Errorf("error during deletion of user access control: %w", delACErr) } } updateMainCommand := &user.UpdateUserCommand{ UserID: intoUser.ID, Login: strings.ToLower(intoUser.Login), Email: strings.ToLower(intoUser.Email), } updateErr := r.userService.Update(ctx, updateMainCommand) if updateErr != nil { return fmt.Errorf("could not update user: %w", updateErr) } return nil }); err != nil { return err } } return nil } /* hej@test.com+hej@test.com all of the permissions, roles and ownership will be transferred to the user. + id: 1, email: hej@test.com, login: hej@test.com these user(s) will be deleted and their permissions transferred. - id: 2, email: HEJ@TEST.COM, login: HEJ@TEST.COM - id: 3, email: hej@TEST.com, login: hej@TEST.com */ func (r *ConflictResolver) showChanges() { if len(r.ValidUsers) == 0 { fmt.Println("no changes will take place as we have no valid users.") return } var b strings.Builder for block, users := range r.Blocks { if _, ok := r.DiscardedBlocks[block]; ok { // skip block continue } // looping as we want to can get these out of order (meaning the + and -) var mainUser ConflictingUser for _, u := range users { if u.Direction == "+" { mainUser = u break } } b.WriteString("Keep the following user.\n") b.WriteString(fmt.Sprintf("%s\n", block)) b.WriteString(color.GreenString(fmt.Sprintf("id: %s, email: %s, login: %s\n", mainUser.ID, mainUser.Email, mainUser.Login))) for _, r := range fmt.Sprintf("%s%s", mainUser.Email, mainUser.Login) { if unicode.IsUpper(r) { b.WriteString("Will be change to:\n") b.WriteString(color.GreenString(fmt.Sprintf("id: %s, email: %s, login: %s\n", mainUser.ID, strings.ToLower(mainUser.Email), strings.ToLower(mainUser.Login)))) break } } b.WriteString("\n\n") b.WriteString("The following user(s) will be deleted.\n") for _, user := range users { if user.ID == mainUser.ID { continue } // mergeable users b.WriteString(color.RedString(fmt.Sprintf("id: %s, email: %s, login: %s\n", user.ID, user.Email, user.Login))) } b.WriteString("\n\n") } logger.Info("\n\nChanges that will take place\n\n") logger.Infof(b.String()) } // Formatter make it possible for us to write to terminal and to a file // with different formats depending on the usecase type Formatter func(format string, a ...any) string func shouldDiscardBlock(seenUsersInBlock map[string]string, block string, user ConflictingUser) bool { // loop through users to see if we should skip this block // we have some more tricky scenarios where we have more than two users that can have conflicts with each other // we have made the approach to discard any users that we have seen if _, ok := seenUsersInBlock[user.ID]; ok { // we have seen the user in different block than the current block if seenUsersInBlock[user.ID] != block { return true } } seenUsersInBlock[user.ID] = block return false } // BuildConflictBlocks builds blocks of users where each block is a unique email/login // NOTE: currently this function assumes that the users are in order of grouping already func (r *ConflictResolver) BuildConflictBlocks(users ConflictingUsers, f Formatter) { discardedBlocks := make(map[string]bool) seenUsersToBlock := make(map[string]string) blocks := make(map[string]ConflictingUsers) for _, user := range users { // conflict blocks is how we identify a conflict in the user base. var conflictBlock string // sqlite generates string : ""/true // postgres generates string : false/true if user.ConflictEmail == "false" { user.ConflictEmail = "" } if user.ConflictLogin == "false" { user.ConflictLogin = "" } if user.ConflictEmail != "" { conflictBlock = f("conflict: %s", strings.ToLower(user.Email)) } else if user.ConflictLogin != "" { conflictBlock = f("conflict: %s", strings.ToLower(user.Login)) } else if user.ConflictEmail != "" && user.ConflictLogin != "" { // both conflicts // should not be here unless changed in sql conflictBlock = f("conflict: %s%s", strings.ToLower(user.Email), strings.ToLower(user.Login)) } // discard logic if shouldDiscardBlock(seenUsersToBlock, conflictBlock, user) { discardedBlocks[conflictBlock] = true } // adding users to blocks if _, ok := blocks[conflictBlock]; !ok { blocks[conflictBlock] = []ConflictingUser{user} continue } // skip user thats already part of the block // since we get duplicate entries if contains(blocks[conflictBlock], user) { continue } blocks[conflictBlock] = append(blocks[conflictBlock], user) } r.Blocks = blocks r.DiscardedBlocks = discardedBlocks } func contains(cu ConflictingUsers, target ConflictingUser) bool { for _, u := range cu { if u.ID == target.ID { return true } } return false } func (r *ConflictResolver) logDiscardedUsers() { keys := make([]string, 0, len(r.DiscardedBlocks)) for block := range r.DiscardedBlocks { for _, u := range r.Blocks[block] { keys = append(keys, u.ID) } } warn := color.YellowString("Note: We discarded some conflicts that have multiple conflicting types involved.") logger.Infof(` %s users discarded with more than one conflict: ids: %s Solve conflicts and run the command again to see other conflicts. `, warn, strings.Join(keys, ",")) } // handling tricky cases:: // if we have seen a user already // note the conflict of that user // discard that conflict for next time that the user runs the command // only present one conflict per user // go through each conflict email/login // if any has ids that have already been seen // discard that conflict // make note to the user to run again after fixing these conflicts func (r *ConflictResolver) ToStringPresentation() string { /* hej@test.com+hej@test.com + id: 1, email: hej@test.com, login: hej@test.com - id: 2, email: HEJ@TEST.COM, login: HEJ@TEST.COM - id: 3, email: hej@TEST.com, login: hej@TEST.com */ startOfBlock := make(map[string]bool) var b strings.Builder for block, users := range r.Blocks { if _, ok := r.DiscardedBlocks[block]; ok { // skip block continue } for _, user := range users { if !startOfBlock[block] { b.WriteString(fmt.Sprintf("%s\n", block)) startOfBlock[block] = true b.WriteString(fmt.Sprintf("+ id: %s, email: %s, login: %s, last_seen_at: %s, auth_module: %s, conflict_email: %s, conflict_login: %s\n", user.ID, user.Email, user.Login, user.LastSeenAt, user.AuthModule, user.ConflictEmail, user.ConflictLogin, )) continue } // mergeable users b.WriteString(fmt.Sprintf("- id: %s, email: %s, login: %s, last_seen_at: %s, auth_module: %s, conflict_email: %s, conflict_login: %s\n", user.ID, user.Email, user.Login, user.LastSeenAt, user.AuthModule, user.ConflictEmail, user.ConflictLogin, )) } } return b.String() } type ConflictResolver struct { Store *sqlstore.SQLStore userService user.Service ac accesscontrol.Service Config *setting.Cfg Users ConflictingUsers ValidUsers ConflictingUsers Blocks map[string]ConflictingUsers DiscardedBlocks map[string]bool } type ConflictingUser struct { // direction is the +/- which indicates if we should keep or delete the user Direction string `xorm:"direction"` ID string `xorm:"id"` Email string `xorm:"email"` Login string `xorm:"login"` LastSeenAt string `xorm:"last_seen_at"` AuthModule string `xorm:"auth_module"` ConflictEmail string `xorm:"conflict_email"` ConflictLogin string `xorm:"conflict_login"` } type ConflictingUsers []ConflictingUser func (c *ConflictingUser) Marshal(filerow string) error { // example view of the file to ingest // +/- id: 1, email: hej, auth_module: LDAP trimmedSpaces := strings.ReplaceAll(filerow, " ", "") if trimmedSpaces[0] == '+' { c.Direction = "+" } else if trimmedSpaces[0] == '-' { c.Direction = "-" } else { return fmt.Errorf("unable to get which operation was chosen") } trimmed := strings.TrimLeft(trimmedSpaces, "+-") values := strings.Split(trimmed, ",") if len(values) < 3 { return fmt.Errorf("expected at least 3 values in entry row") } // expected fields id := strings.Split(values[0], ":") email := strings.Split(values[1], ":") login := strings.Split(values[2], ":") c.ID = id[1] c.Email = email[1] c.Login = login[1] // why trim values, 2022-08-20:19:17:12 lastSeenAt := strings.TrimPrefix(values[3], "last_seen_at:") authModule := strings.Split(values[4], ":") if len(authModule) < 2 { c.AuthModule = "" } else { c.AuthModule = authModule[1] } c.LastSeenAt = lastSeenAt // which conflict conflictEmail := strings.Split(values[5], ":") conflictLogin := strings.Split(values[6], ":") if len(conflictEmail) < 2 { c.ConflictEmail = "" } else { c.ConflictEmail = conflictEmail[1] } if len(conflictLogin) < 2 { c.ConflictLogin = "" } else { c.ConflictLogin = conflictLogin[1] } return nil } func GetUsersWithConflictingEmailsOrLogins(ctx *cli.Context, s *sqlstore.SQLStore) (ConflictingUsers, error) { queryUsers := make([]ConflictingUser, 0) outerErr := s.WithDbSession(ctx.Context, func(dbSession *db.Session) error { var rawSQL string if s.GetDialect().DriverName() == migrator.Postgres { rawSQL = conflictUserEntriesSQLPostgres() } else if s.GetDialect().DriverName() == migrator.SQLite { rawSQL = conflictingUserEntriesSQL(s) } err := dbSession.SQL(rawSQL).Find(&queryUsers) return err }) if outerErr != nil { return queryUsers, outerErr } return queryUsers, nil } // conflictingUserEntriesSQL orders conflicting users by their user_identification // sorts the users by their useridentification and ids func conflictingUserEntriesSQL(s *sqlstore.SQLStore) string { userDialect := db.DB.GetDialect(s).Quote("user") sqlQuery := ` SELECT DISTINCT u1.id, u1.email, u1.login, u1.last_seen_at, user_auth.auth_module, ( SELECT 'true' FROM ` + userDialect + ` WHERE (LOWER(u1.email) = LOWER(u2.email)) AND(u1.email != u2.email)) AS conflict_email, ( SELECT 'true' FROM ` + userDialect + ` WHERE (LOWER(u1.login) = LOWER(u2.login) AND(u1.login != u2.login))) AS conflict_login FROM ` + userDialect + ` AS u1, ` + userDialect + ` AS u2 LEFT JOIN user_auth on user_auth.user_id = u1.id WHERE (conflict_email IS NOT NULL OR conflict_login IS NOT NULL) AND (u1.` + notServiceAccount(s) + `) ORDER BY conflict_email, conflict_login, u1.id` return sqlQuery } func conflictUserEntriesSQLPostgres() string { sqlQuery := ` SELECT DISTINCT u1.id, u1.email, u1.login, u1.last_seen_at, ua.auth_module, ((LOWER(u1.email) = LOWER(u2.email)) AND(u1.email != u2.email)) AS conflict_email, ((LOWER(u1.login) = LOWER(u2.login)) AND(u1.login != u2.login)) AS conflict_login FROM "user" AS u1, "user" AS u2 LEFT JOIN user_auth AS ua ON ua.user_id = u2.id WHERE ((LOWER(u1.email) = LOWER(u2.email)) AND(u1.email != u2.email)) IS TRUE OR((LOWER(u1.login) = LOWER(u2.login)) AND(u1.login != u2.login)) IS TRUE AND(u1.is_service_account = FALSE) ORDER BY conflict_email, conflict_login, u1.id; ; ` return sqlQuery } func notServiceAccount(ss *sqlstore.SQLStore) string { return fmt.Sprintf("is_service_account = %s", ss.GetDialect().BooleanStr(false)) } // confirm function asks for user input // returns bool func confirm(confirmPrompt string) bool { var input string logger.Infof("%s? [y|n]: ", confirmPrompt) _, err := fmt.Scanln(&input) if err != nil { logger.Infof("could not parse input from user for confirmation") return false } input = strings.ToLower(input) if input == "y" || input == "yes" { return true } return false }