diff --git a/pkg/cmd/grafana-cli/commands/commands.go b/pkg/cmd/grafana-cli/commands/commands.go index 631bdc5c744..b2fbda1dbd5 100644 --- a/pkg/cmd/grafana-cli/commands/commands.go +++ b/pkg/cmd/grafana-cli/commands/commands.go @@ -192,6 +192,18 @@ var adminCommands = []*cli.Command{ }, }, }, + { + Name: "users-manager", + Usage: "Runs different helpful user commands", + Subcommands: []*cli.Command{ + { + Name: "list-duplicates", + Usage: "list-duplicate-users returns a list of users with duplicate entries in the database. Safe to execute multiple times.", + Action: runConflictingUsersCommand(), + }, + // TODO: reset password for user + }, + }, } var Commands = []*cli.Command{ diff --git a/pkg/cmd/grafana-cli/commands/duplicate_user_command.go b/pkg/cmd/grafana-cli/commands/duplicate_user_command.go new file mode 100644 index 00000000000..867b72fd528 --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/duplicate_user_command.go @@ -0,0 +1,181 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/fatih/color" + "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/tracing" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/db" + "github.com/grafana/grafana/pkg/services/sqlstore/migrations" + "github.com/urfave/cli/v2" +) + +// listRemoteCommand prints out all plugins in the remote repo with latest version supported on current platform. +// If there are no supported versions for plugin it is skipped. + +/* +FIXME: wording from Conflicting to multiple as a user might have more than two emails/logins, could be confusing +to have the word Conflicting for this +new word for Conflicting : conflicting (prefered now) / multiple + + TODO: also consider same login and email but different ids: + login == email, id.login != id.email + + user-manager command listConflictingUsers + lists users w. Conflicting emails or logins as + `ids | Conflicting-emails | Conflicting-logins` + `{4,5} | {user@test.com, user@TEST.com} | {user@test.com}` + user-manager command merge-Conflicting-users + input -> users + output -> ids, foundConflictingEmails, foundConflictingLogins, lastActive (maybe) + + + // IDENTIFIER + login = username + email + + # proposal 1 - one command to list and merge + type conflictUser struct { + ids []int + login string + conflictEmails []string + conflictLogins []string + } + type conflictUsers []conflictUsers + + users, err := getUsersWithConflictingEmailsOrLogins() + if len(users) == 0 { + return "no users found" + } + // present all users + showUsers() + + // ask if user want to start merging + for u in := range (users) { + // they can now quit + promptIfUserWantToMerge() + + // recursiveFunction to merge users + + // two options + // updateUserDetails(login, newValue, field) + // || + // mergeInto(login) + // NOTE: need to update all userIds in all tables for the one you mergeFrom + // keep id of the mergeInto <- mergeFrom . update userId to mergeInto in the mergeFrom user + mergeUser(u) + } +*/ + +func runConflictingUsersCommand() func(context *cli.Context) error { + return func(context *cli.Context) error { + cmd := &utils.ContextCommandLine{Context: context} + + cfg, err := initCfg(cmd) + if err != nil { + return fmt.Errorf("%v: %w", "failed to load configuration", err) + } + + tracer, err := tracing.ProvideService(cfg) + if err != nil { + return fmt.Errorf("%v: %w", "failed to initialize tracer service", err) + } + + bus := bus.ProvideBus(tracer) + + sqlStore, err := sqlstore.ProvideService(cfg, nil, &migrations.OSSMigrations{}, bus, tracer) + if err != nil { + return fmt.Errorf("%v: %w", "failed to initialize SQL store", err) + } + + users, err := GetUsersWithConflictingEmailsOrLogins(context.Context, sqlStore) + if err != nil { + return fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) + } + + if len(users) < 1 { + logger.Info(color.GreenString("No Conflicting users found.\n\n")) + return nil + } + // present all users + showUsers() + + for _, u := range users { + logger.Infof("id: %v version: %s\n", u.UserIdentifier, u.Ids) + } + + return nil + } +} + +func showUsers() { + // TODO: present users elegantly :D + logger.Info(color.GreenString("users here")) +} + +type conflictUser struct { + Ids string `xorm:"ids"` + // IDENTIFIER + // userIdentifier = login + email + UserIdentifier string `xorm:"user_identification"` + ConflictEmails string `xorm:"conflicting_emails"` + ConflictLogins string `xorm:"conflicting_logins"` +} +type conflictUsers []conflictUser + +func GetUsersWithConflictingEmailsOrLogins(ctx context.Context, s *sqlstore.SQLStore) (conflictUsers, error) { + var stats conflictUsers + outerErr := s.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + rawSQL := conflictingUserEntriesSQL(s) + err := dbSession.SQL(rawSQL).Find(&stats) + return err + }) + if outerErr != nil { + return stats, outerErr + } + return stats, nil +} +func conflictingUserEntriesSQL(s *sqlstore.SQLStore) string { + userDialect := db.DB.GetDialect(s).Quote("user") + // this query counts how many users have the same login or email. + // which might be confusing, but gives a good indication + // we want this query to not require too much cpu + sqlQuery := ` + SELECT + u1.login || u1.email AS user_identification, + group_concat(u1.id, ',') AS ids, + group_concat(u1.email, ',') AS conflicting_emails, + group_concat(u1.login, ',') AS conflicting_logins, + ( + SELECT + u1.email + FROM + ` + userDialect + ` + WHERE (LOWER(u1.email) = LOWER(u2.email)) + AND(u1.email != u2.email)) AS dup_email, ( + SELECT + u1.login + FROM + ` + userDialect + ` + WHERE (LOWER(u1.login) = LOWER(u2.login) + AND(u1.login != u2.login))) AS dup_login, ( + SELECT + u1.id + FROM + ` + userDialect + ` + WHERE ((u1.login = u2.login) + AND(u1.email = u2.email) + AND(u1.id != u2.id))) AS dup_ids + FROM + ` + userDialect + ` AS u1, ` + userDialect + ` AS u2 + WHERE (dup_email IS NOT NULL + OR dup_login IS NOT NULL OR dup_ids IS NOT NULL) +GROUP BY + LOWER(user_identification); + ` + return sqlQuery +} diff --git a/pkg/cmd/grafana-cli/commands/duplicate_user_command_test.go b/pkg/cmd/grafana-cli/commands/duplicate_user_command_test.go new file mode 100644 index 00000000000..d1281a96144 --- /dev/null +++ b/pkg/cmd/grafana-cli/commands/duplicate_user_command_test.go @@ -0,0 +1,54 @@ +package commands + +import ( + "context" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/require" +) + +func TestUserManagerListConflictingUsers(t *testing.T) { + t.Run("should get conflicting users", func(t *testing.T) { + // Restore after destructive operation + sqlStore := sqlstore.InitTestDB(t) + + for i := 0; i < 5; i++ { + cmd := user.CreateUserCommand{ + Email: fmt.Sprint("user", i, "@test.com"), + Name: fmt.Sprint("user", i), + Login: fmt.Sprint("loginuser", i), + OrgID: 1, + } + _, err := sqlStore.CreateUser(context.Background(), cmd) + require.Nil(t, err) + } + + // "Skipping duplicate users test for mysql as it does make unique constraint case insensitive by default + if sqlStore.GetDialect().DriverName() != "mysql" { + dupUserEmailcmd := user.CreateUserCommand{ + Email: "USERDUPLICATETEST1@TEST.COM", + Name: "user name 1", + Login: "USER_DUPLICATE_TEST_1_LOGIN", + } + _, err := sqlStore.CreateUser(context.Background(), dupUserEmailcmd) + require.NoError(t, err) + + // add additional user with duplicate login where DOMAIN is upper case + dupUserLogincmd := user.CreateUserCommand{ + Email: "userduplicatetest1@test.com", + Name: "user name 1", + Login: "user_duplicate_test_1_login", + } + _, err = sqlStore.CreateUser(context.Background(), dupUserLogincmd) + require.NoError(t, err) + // require metrics and statistics to be 2 + m, err := GetUsersWithConflictingEmailsOrLogins(context.Background(), sqlStore) + t.Logf("%v+", m) + require.NoError(t, err) + require.Equal(t, 1, len(m)) + } + }) +} diff --git a/pkg/services/login/authinfoservice/database/usagestats.go b/pkg/services/login/authinfoservice/database/usagestats.go index ed949400864..2c1bd0d3528 100644 --- a/pkg/services/login/authinfoservice/database/usagestats.go +++ b/pkg/services/login/authinfoservice/database/usagestats.go @@ -14,7 +14,7 @@ type loginStats struct { func (s *AuthInfoStore) GetLoginStats(ctx context.Context) (loginStats, error) { var stats loginStats outerErr := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { - rawSQL := `SELECT COUNT(*) as duplicate_user_entries FROM (` + s.duplicateUserEntriesSQL(ctx) + `)` + rawSQL := `SELECT COUNT(*) as duplicate_user_entries FROM (` + s.DuplicateUserEntriesSQL(ctx) + `)` _, err := dbSession.SQL(rawSQL).Get(&stats) return err }) @@ -42,7 +42,7 @@ func (s *AuthInfoStore) CollectLoginStats(ctx context.Context) (map[string]inter return m, nil } -func (s *AuthInfoStore) duplicateUserEntriesSQL(ctx context.Context) string { +func (s *AuthInfoStore) DuplicateUserEntriesSQL(ctx context.Context) string { userDialect := db.DB.GetDialect(s.sqlStore).Quote("user") // this query counts how many users have the same login or email. // which might be confusing, but gives a good indication