add users-manager command

This commit is contained in:
eleijonmarck
2022-07-19 11:09:33 +01:00
parent 13b23fd512
commit 8f7a8c02e7
4 changed files with 249 additions and 2 deletions
+12
View File
@@ -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{
@@ -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
}
@@ -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))
}
})
}
@@ -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