Auth: Add empty role usage metrics for service and user accounts (#73108)

* Add tests for service accounts metrics usage

* Add service account store implementation

* Add service account service implementation

* Add tests for org metrics usage

* Add org implementation

* Add service implementation
This commit is contained in:
linoman 2023-08-16 10:56:47 +02:00 committed by GitHub
parent 75b0788377
commit 1c7f89c41b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 155 additions and 17 deletions

View File

@ -12,10 +12,16 @@ func (s *ServiceAccountsStoreImpl) GetUsageMetrics(ctx context.Context) (*servic
sb := &db.SQLBuilder{} sb := &db.SQLBuilder{}
sb.Write("SELECT ") sb.Write("SELECT ")
sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` ` +
` WHERE is_service_account = ` + dialect.BooleanStr(true) + `) AS serviceaccounts,`) `WHERE is_service_account = ` + dialect.BooleanStr(true) + `) AS serviceaccounts,`)
sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("api_key") + sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("api_key") +
` WHERE service_account_id IS NOT NULL ) AS serviceaccount_tokens`) `WHERE service_account_id IS NOT NULL ) AS serviceaccount_tokens,`)
sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("org_user") + ` AS ou ` +
`JOIN ` + dialect.Quote("user") + ` AS u ON u.id = ou.user_id ` +
`WHERE u.is_disabled = ` + dialect.BooleanStr(false) + ` ` +
`AND u.is_service_account = ` + dialect.BooleanStr(true) + ` ` +
`AND ou.role=?) AS serviceaccounts_with_no_role`)
sb.AddParams("None")
var sqlStats serviceaccounts.Stats var sqlStats serviceaccounts.Stats
if err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error { if err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {

View File

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/satokengen" "github.com/grafana/grafana/pkg/components/satokengen"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
) )
@ -33,10 +34,18 @@ func TestIntegrationStore_UsageStats(t *testing.T) {
_, err = store.AddServiceAccountToken(context.Background(), sa.ID, &cmd) _, err = store.AddServiceAccountToken(context.Background(), sa.ID, &cmd)
require.NoError(t, err) require.NoError(t, err)
role := org.RoleNone
form := serviceaccounts.UpdateServiceAccountForm{
Role: &role,
}
_, err = store.UpdateServiceAccount(context.Background(), sa.OrgID, sa.ID, &form)
require.NoError(t, err)
stats, err := store.GetUsageMetrics(context.Background()) stats, err := store.GetUsageMetrics(context.Background())
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int64(1), stats.ServiceAccounts) assert.Equal(t, int64(1), stats.ServiceAccounts)
assert.Equal(t, int64(1), stats.ServiceAccountsWithNoRole)
assert.Equal(t, int64(1), stats.Tokens) assert.Equal(t, int64(1), stats.Tokens)
assert.Equal(t, true, stats.ForcedExpiryEnabled) assert.Equal(t, true, stats.ForcedExpiryEnabled)
} }

View File

@ -14,6 +14,9 @@ var (
// MStatTotalServiceAccounts is a metric gauge for total number of service accounts // MStatTotalServiceAccounts is a metric gauge for total number of service accounts
MStatTotalServiceAccounts prometheus.Gauge MStatTotalServiceAccounts prometheus.Gauge
// MStatTotalServiceAccountsNoRole is a metric gauge for total number of user accounts with no role
MStatTotalServiceAccountsNoRole prometheus.Gauge
// MStatTotalServiceAccountTokens is a metric gauge for total number of service account tokens // MStatTotalServiceAccountTokens is a metric gauge for total number of service account tokens
MStatTotalServiceAccountTokens prometheus.Gauge MStatTotalServiceAccountTokens prometheus.Gauge
@ -27,6 +30,12 @@ func init() {
Namespace: ExporterName, Namespace: ExporterName,
}) })
MStatTotalServiceAccountsNoRole = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "stat_total_service_accounts_role_none",
Help: "total amount of service accounts with no role",
Namespace: ExporterName,
})
MStatTotalServiceAccountTokens = prometheus.NewGauge(prometheus.GaugeOpts{ MStatTotalServiceAccountTokens = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "stat_total_service_account_tokens", Name: "stat_total_service_account_tokens",
Help: "total amount of service account tokens", Help: "total amount of service account tokens",
@ -36,6 +45,7 @@ func init() {
prometheus.MustRegister( prometheus.MustRegister(
MStatTotalServiceAccounts, MStatTotalServiceAccounts,
MStatTotalServiceAccountTokens, MStatTotalServiceAccountTokens,
MStatTotalServiceAccountsNoRole,
) )
} }
@ -48,6 +58,7 @@ func (sa *ServiceAccountsService) getUsageMetrics(ctx context.Context) (map[stri
} }
stats["stats.serviceaccounts.count"] = storeStats.ServiceAccounts stats["stats.serviceaccounts.count"] = storeStats.ServiceAccounts
stats["stats.serviceaccounts.role_none.count"] = storeStats.ServiceAccountsWithNoRole
stats["stats.serviceaccounts.tokens.count"] = storeStats.Tokens stats["stats.serviceaccounts.tokens.count"] = storeStats.Tokens
var forcedExpiryEnabled int64 = 0 var forcedExpiryEnabled int64 = 0
@ -64,8 +75,9 @@ func (sa *ServiceAccountsService) getUsageMetrics(ctx context.Context) (map[stri
stats["stats.serviceaccounts.secret_scan.enabled.count"] = secretScanEnabled stats["stats.serviceaccounts.secret_scan.enabled.count"] = secretScanEnabled
MStatTotalServiceAccountTokens.Set(float64(storeStats.Tokens))
MStatTotalServiceAccounts.Set(float64(storeStats.ServiceAccounts)) MStatTotalServiceAccounts.Set(float64(storeStats.ServiceAccounts))
MStatTotalServiceAccountsNoRole.Set(float64(storeStats.ServiceAccountsWithNoRole))
MStatTotalServiceAccountTokens.Set(float64(storeStats.Tokens))
return stats, nil return stats, nil
} }

View File

@ -18,15 +18,17 @@ func Test_UsageStats(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
storeMock.ExpectedStats = &serviceaccounts.Stats{ storeMock.ExpectedStats = &serviceaccounts.Stats{
ServiceAccounts: 1, ServiceAccounts: 1,
Tokens: 1, ServiceAccountsWithNoRole: 1,
ForcedExpiryEnabled: false, Tokens: 1,
ForcedExpiryEnabled: false,
} }
stats, err := svc.getUsageMetrics(context.Background()) stats, err := svc.getUsageMetrics(context.Background())
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, stats, 4, stats) assert.Len(t, stats, 5, stats)
assert.Equal(t, int64(1), stats["stats.serviceaccounts.count"].(int64)) assert.Equal(t, int64(1), stats["stats.serviceaccounts.count"].(int64))
assert.Equal(t, int64(1), stats["stats.serviceaccounts.role_none.count"].(int64))
assert.Equal(t, int64(1), stats["stats.serviceaccounts.tokens.count"].(int64)) assert.Equal(t, int64(1), stats["stats.serviceaccounts.tokens.count"].(int64))
assert.Equal(t, int64(1), stats["stats.serviceaccounts.secret_scan.enabled.count"].(int64)) assert.Equal(t, int64(1), stats["stats.serviceaccounts.secret_scan.enabled.count"].(int64))
assert.Equal(t, int64(0), stats["stats.serviceaccounts.forced_expiry_enabled.count"].(int64)) assert.Equal(t, int64(0), stats["stats.serviceaccounts.forced_expiry_enabled.count"].(int64))

View File

@ -160,9 +160,10 @@ const (
) )
type Stats struct { type Stats struct {
ServiceAccounts int64 `xorm:"serviceaccounts"` ServiceAccounts int64 `xorm:"serviceaccounts"`
Tokens int64 `xorm:"serviceaccount_tokens"` ServiceAccountsWithNoRole int64 `xorm:"serviceaccounts_with_no_role"`
ForcedExpiryEnabled bool `xorm:"-"` Tokens int64 `xorm:"serviceaccount_tokens"`
ForcedExpiryEnabled bool `xorm:"-"`
} }
// AccessEvaluator is used to protect the "Configuration > Service accounts" page access // AccessEvaluator is used to protect the "Configuration > Service accounts" page access

View File

@ -40,6 +40,7 @@ type store interface {
Search(context.Context, *user.SearchUsersQuery) (*user.SearchUserQueryResult, error) Search(context.Context, *user.SearchUsersQuery) (*user.SearchUserQueryResult, error)
Count(ctx context.Context) (int64, error) Count(ctx context.Context) (int64, error)
CountUserAccountsWithEmptyRole(ctx context.Context) (int64, error)
} }
type sqlStore struct { type sqlStore struct {
@ -532,6 +533,27 @@ func (ss *sqlStore) Count(ctx context.Context) (int64, error) {
return r.Count, err return r.Count, err
} }
func (ss *sqlStore) CountUserAccountsWithEmptyRole(ctx context.Context) (int64, error) {
sb := &db.SQLBuilder{}
sb.Write("SELECT ")
sb.Write(`(SELECT COUNT (*) from ` + ss.dialect.Quote("org_user") + ` AS ou ` +
`LEFT JOIN ` + ss.dialect.Quote("user") + ` AS u ON u.id = ou.user_id ` +
`WHERE ou.role =? ` +
`AND u.is_service_account = ` + ss.dialect.BooleanStr(false) + ` ` +
`AND u.is_disabled = ` + ss.dialect.BooleanStr(false) + `) AS user_accounts_with_no_role`)
sb.AddParams("None")
var countStats int64
if err := ss.db.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.SQL(sb.GetSQLString(), sb.GetParams()...).Get(&countStats)
return err
}); err != nil {
return -1, err
}
return countStats, nil
}
// validateOneAdminLeft validate that there is an admin user left // validateOneAdminLeft validate that there is an admin user left
func validateOneAdminLeft(ctx context.Context, sess *db.Session) error { func validateOneAdminLeft(ctx context.Context, sess *db.Session) error {
count, err := sess.Where("is_admin=?", true).Count(&user.User{}) count, err := sess.Where("is_admin=?", true).Count(&user.User{})

View File

@ -956,6 +956,53 @@ func updateDashboardACL(t *testing.T, sqlStore db.DB, dashboardID int64, items .
return err return err
} }
func TestMetricsUsage(t *testing.T) {
ss := db.InitTestDB(t)
userStore := ProvideStore(ss, setting.NewCfg())
quotaService := quotaimpl.ProvideService(ss, ss.Cfg)
orgService, err := orgimpl.ProvideService(ss, ss.Cfg, quotaService)
require.NoError(t, err)
_, usrSvc := createOrgAndUserSvc(t, ss, ss.Cfg)
t.Run("", func(t *testing.T) {
orgId := int64(1)
// create first user
createFirtUserCmd := &user.CreateUserCommand{
Login: "admin",
Email: "admin@admin.com",
Name: "admin",
OrgID: orgId,
}
_, err := usrSvc.Create(context.Background(), createFirtUserCmd)
require.NoError(t, err)
// create second user
createSecondUserCmd := &user.CreateUserCommand{
Login: "userWithoutRole",
Email: "userWithoutRole@userWithoutRole.com",
Name: "userWithoutRole",
}
secondUser, err := usrSvc.Create(context.Background(), createSecondUserCmd)
require.NoError(t, err)
// assign the user to the org
cmd := org.AddOrgUserCommand{
OrgID: secondUser.OrgID,
UserID: orgId,
Role: org.RoleNone,
}
err = orgService.AddOrgUser(context.Background(), &cmd)
require.NoError(t, err)
// get metric usage
stats, err := userStore.CountUserAccountsWithEmptyRole(context.Background())
require.NoError(t, err)
assert.Equal(t, int64(1), stats)
})
}
// This function was copied from pkg/services/dashboards/database to circumvent // This function was copied from pkg/services/dashboards/database to circumvent
// import cycles. When this org-related code is refactored into a service the // import cycles. When this org-related code is refactored into a service the
// tests can the real GetDashboardACLInfoList functions // tests can the real GetDashboardACLInfoList functions

View File

@ -72,6 +72,14 @@ func (s *Service) GetUsageStats(ctx context.Context) map[string]interface{} {
} }
stats["stats.case_insensitive_login.count"] = caseInsensitiveLoginVal stats["stats.case_insensitive_login.count"] = caseInsensitiveLoginVal
count, err := s.store.CountUserAccountsWithEmptyRole(ctx)
if err != nil {
return nil
}
stats["stats.user.role_none.count"] = count
return stats return stats
} }

View File

@ -193,13 +193,40 @@ func TestUserService(t *testing.T) {
}) })
} }
func TestMetrics(t *testing.T) {
userStore := newUserStoreFake()
orgService := orgtest.NewOrgServiceFake()
userService := Service{
store: userStore,
orgService: orgService,
cacheService: localcache.ProvideService(),
teamService: &teamtest.FakeService{},
}
t.Run("update user with role None", func(t *testing.T) {
userStore.ExpectedCountUserAccountsWithEmptyRoles = int64(1)
userService.cfg = setting.NewCfg()
userService.cfg.CaseInsensitiveLogin = true
stats := userService.GetUsageStats(context.Background())
assert.NotEmpty(t, stats)
assert.Len(t, stats, 2, stats)
assert.Equal(t, 1, stats["stats.case_insensitive_login.count"])
assert.Equal(t, int64(1), stats["stats.user.role_none.count"])
})
}
type FakeUserStore struct { type FakeUserStore struct {
ExpectedUser *user.User ExpectedUser *user.User
ExpectedSignedInUser *user.SignedInUser ExpectedSignedInUser *user.SignedInUser
ExpectedUserProfile *user.UserProfileDTO ExpectedUserProfile *user.UserProfileDTO
ExpectedSearchUserQueryResult *user.SearchUserQueryResult ExpectedSearchUserQueryResult *user.SearchUserQueryResult
ExpectedError error ExpectedError error
ExpectedDeleteUserError error ExpectedDeleteUserError error
ExpectedCountUserAccountsWithEmptyRoles int64
} }
func newUserStoreFake() *FakeUserStore { func newUserStoreFake() *FakeUserStore {
@ -290,6 +317,10 @@ func (f *FakeUserStore) Count(ctx context.Context) (int64, error) {
return 0, nil return 0, nil
} }
func (f *FakeUserStore) CountUserAccountsWithEmptyRole(ctx context.Context) (int64, error) {
return f.ExpectedCountUserAccountsWithEmptyRoles, nil
}
func TestUpdateLastSeenAt(t *testing.T) { func TestUpdateLastSeenAt(t *testing.T) {
userStore := newUserStoreFake() userStore := newUserStoreFake()
orgService := orgtest.NewOrgServiceFake() orgService := orgtest.NewOrgServiceFake()