diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index a46b4ab94ce..f51f29f6620 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/live/pushhttp" + "github.com/grafana/grafana/pkg/services/login/authinfoservice" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/notifications" plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service" @@ -40,7 +41,7 @@ func ProvideBackgroundServiceRegistry( pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService, - saService *samanager.ServiceAccountsService, + saService *samanager.ServiceAccountsService, authInfoService *authinfoservice.Implementation, // Need to make sure these are initialized, is there a better place to put them? _ dashboardsnapshots.Service, _ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider, @@ -71,6 +72,7 @@ func ProvideBackgroundServiceRegistry( searchService, entityEventsService, saService, + authInfoService, ) } diff --git a/pkg/services/login/authinfoservice/database/database.go b/pkg/services/login/authinfoservice/database/database.go index a2049c1a1ac..af8f099ecf9 100644 --- a/pkg/services/login/authinfoservice/database/database.go +++ b/pkg/services/login/authinfoservice/database/database.go @@ -26,6 +26,7 @@ func ProvideAuthInfoStore(sqlStore sqlstore.Store, secretsService secrets.Servic secretsService: secretsService, logger: log.New("login.authinfo.store"), } + InitMetrics() return store } diff --git a/pkg/services/login/authinfoservice/database/stats.go b/pkg/services/login/authinfoservice/database/stats.go new file mode 100644 index 00000000000..c34da9b34da --- /dev/null +++ b/pkg/services/login/authinfoservice/database/stats.go @@ -0,0 +1,150 @@ +package database + +import ( + "context" + "sync" + "time" + + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/db" + "github.com/prometheus/client_golang/prometheus" +) + +type LoginStats struct { + DuplicateUserEntries int `xorm:"duplicate_user_entries"` + MixedCasedUsers int `xorm:"mixed_cased_users"` +} + +const ( + ExporterName = "grafana" + metricsCollectionInterval = time.Second * 60 * 4 // every 4 hours, indication of duplicate users +) + +var ( + // MStatDuplicateUserEntries is a indication metric gauge for number of users with duplicate emails or logins + MStatDuplicateUserEntries prometheus.Gauge + + // MStatHasDuplicateEntries is a metric for if there is duplicate users + MStatHasDuplicateEntries prometheus.Gauge + + // MStatMixedCasedUsers is a metric for if there is duplicate users + MStatMixedCasedUsers prometheus.Gauge + + once sync.Once + Initialised bool = false +) + +func InitMetrics() { + once.Do(func() { + MStatDuplicateUserEntries = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_users_total_duplicate_user_entries", + Help: "total number of duplicate user entries by email or login", + Namespace: ExporterName, + }) + + MStatHasDuplicateEntries = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_users_has_duplicate_user_entries", + Help: "instance has duplicate user entries by email or login", + Namespace: ExporterName, + }) + + MStatMixedCasedUsers = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_users_total_mixed_cased_users", + Help: "total number of users with upper and lower case logins or emails", + Namespace: ExporterName, + }) + + prometheus.MustRegister( + MStatDuplicateUserEntries, + MStatHasDuplicateEntries, + MStatMixedCasedUsers, + ) + }) +} + +func (s *AuthInfoStore) RunMetricsCollection(ctx context.Context) error { + if _, err := s.GetLoginStats(ctx); err != nil { + s.logger.Warn("Failed to get authinfo metrics", "error", err.Error()) + } + updateStatsTicker := time.NewTicker(metricsCollectionInterval) + defer updateStatsTicker.Stop() + + for { + select { + case <-updateStatsTicker.C: + if _, err := s.GetLoginStats(ctx); err != nil { + s.logger.Warn("Failed to get authinfo metrics", "error", err.Error()) + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (s *AuthInfoStore) GetLoginStats(ctx context.Context) (LoginStats, error) { + var stats LoginStats + outerErr := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + rawSQL := `SELECT + (SELECT COUNT(*) FROM (` + s.duplicateUserEntriesSQL(ctx) + `) AS d WHERE (d.dup_login IS NOT NULL OR d.dup_email IS NOT NULL)) as duplicate_user_entries, + (SELECT COUNT(*) FROM (` + s.mixedCasedUsers(ctx) + `) AS mcu) AS mixed_cased_users + ` + _, err := dbSession.SQL(rawSQL).Get(&stats) + return err + }) + if outerErr != nil { + return stats, outerErr + } + + // set prometheus metrics stats + MStatDuplicateUserEntries.Set(float64(stats.DuplicateUserEntries)) + if stats.DuplicateUserEntries == 0 { + MStatHasDuplicateEntries.Set(float64(0)) + } else { + MStatHasDuplicateEntries.Set(float64(1)) + } + + MStatMixedCasedUsers.Set(float64(stats.MixedCasedUsers)) + return stats, nil +} + +func (s *AuthInfoStore) CollectLoginStats(ctx context.Context) (map[string]interface{}, error) { + m := map[string]interface{}{} + + loginStats, err := s.GetLoginStats(ctx) + if err != nil { + s.logger.Error("Failed to get login stats", "error", err) + return nil, err + } + + m["stats.users.duplicate_user_entries"] = loginStats.DuplicateUserEntries + if loginStats.DuplicateUserEntries > 0 { + m["stats.users.has_duplicate_user_entries"] = 1 + } else { + m["stats.users.has_duplicate_user_entries"] = 0 + } + + m["stats.users.mixed_cased_users"] = loginStats.MixedCasedUsers + + return m, nil +} + +func (s *AuthInfoStore) duplicateUserEntriesSQL(ctx context.Context) string { + userDialect := s.sqlStore.GetDialect().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 + (SELECT login from ` + userDialect + ` WHERE (LOWER(login) = LOWER(u.login)) AND (login != u.login)) AS dup_login, + (SELECT email from ` + userDialect + ` WHERE (LOWER(email) = LOWER(u.email)) AND (email != u.email)) AS dup_email + FROM ` + userDialect + ` AS u` + return sqlQuery +} + +func (s *AuthInfoStore) mixedCasedUsers(ctx context.Context) string { + userDialect := db.DB.GetDialect(s.sqlStore).Quote("user") + // this query counts how many users have upper case and lower case login or emails. + // why + // users login via IDP or service providers get upper cased domains at times :shrug: + sqlQuery := `SELECT login, email FROM ` + userDialect + ` WHERE (LOWER(login) != login OR lower(email) != email)` + return sqlQuery +} diff --git a/pkg/services/login/authinfoservice/service.go b/pkg/services/login/authinfoservice/service.go index 23926920219..ac713a7345b 100644 --- a/pkg/services/login/authinfoservice/service.go +++ b/pkg/services/login/authinfoservice/service.go @@ -196,3 +196,8 @@ func (s *Implementation) SetAuthInfo(ctx context.Context, cmd *models.SetAuthInf func (s *Implementation) GetExternalUserInfoByLogin(ctx context.Context, query *models.GetExternalUserInfoByLoginQuery) error { return s.authInfoStore.GetExternalUserInfoByLogin(ctx, query) } + +func (s *Implementation) Run(ctx context.Context) error { + s.logger.Debug("Started AuthInfo Metrics collection service") + return s.authInfoStore.RunMetricsCollection(ctx) +} diff --git a/pkg/services/login/authinfoservice/user_auth_test.go b/pkg/services/login/authinfoservice/user_auth_test.go index a00e36522b7..668ce91a5ce 100644 --- a/pkg/services/login/authinfoservice/user_auth_test.go +++ b/pkg/services/login/authinfoservice/user_auth_test.go @@ -371,6 +371,28 @@ func TestUserAuth(t *testing.T) { require.Nil(t, user) }) + t.Run("should be able to run loginstats query in all dbs", func(t *testing.T) { + // we need to see that we can run queries for all db + // as it is only a concern for postgres/sqllite3 + // where we have duplicate users + + // 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) + } + + _, err := srv.authInfoStore.GetLoginStats(context.Background()) + require.Nil(t, err) + }) + t.Run("calculate metrics on duplicate userstats", func(t *testing.T) { // Restore after destructive operation sqlStore = sqlstore.InitTestDB(t) @@ -404,11 +426,14 @@ func TestUserAuth(t *testing.T) { } _, err = sqlStore.CreateUser(context.Background(), dupUserLogincmd) require.NoError(t, err) - // require metrics and statistics to be 2 + + // require stats to populate m, err := srv.authInfoStore.CollectLoginStats(context.Background()) require.NoError(t, err) require.Equal(t, 2, m["stats.users.duplicate_user_entries"]) require.Equal(t, 1, m["stats.users.has_duplicate_user_entries"]) + + require.Equal(t, 1, m["stats.users.mixed_cased_users"]) } }) }) diff --git a/pkg/services/login/userprotection.go b/pkg/services/login/userprotection.go index 3f2f0c8ef1f..725343eb3f0 100644 --- a/pkg/services/login/userprotection.go +++ b/pkg/services/login/userprotection.go @@ -4,6 +4,7 @@ import ( "context" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/user" ) @@ -22,4 +23,6 @@ type Store interface { GetUserByLogin(ctx context.Context, login string) (*user.User, error) GetUserByEmail(ctx context.Context, email string) (*user.User, error) CollectLoginStats(ctx context.Context) (map[string]interface{}, error) + RunMetricsCollection(ctx context.Context) error + GetLoginStats(ctx context.Context) (database.LoginStats, error) }