diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index c51f181b116..e1c84b604be 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -99,7 +99,7 @@ func TestMergeUser(t *testing.T) { t.Logf("testing getting user") query := &models.GetUserByIdQuery{Id: userWithUpperCase.ID} err = sqlStore.GetUserById(context.Background(), query) - require.Error(t, models.ErrUserNotFound, err) + require.Error(t, user.ErrUserNotFound, err) testUser := &models.SignedInUser{ OrgId: testOrgID, 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 +}