diff --git a/conf/defaults.ini b/conf/defaults.ini index 2dfcbc85756..d5c221c4957 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -651,6 +651,9 @@ org_data_source = 10 # limit number of api_keys per Org. org_api_key = 10 +# limit number of alerts per Org. +org_alert_rule = 100 + # limit number of orgs a user can create. user_org = 10 @@ -669,6 +672,9 @@ global_api_key = -1 # global limit on number of logged in users. global_session = -1 +# global limit of alerts +global_alert_rule = -1 + #################################### Alerting ############################ [alerting] # Disable alerting engine & UI features diff --git a/conf/sample.ini b/conf/sample.ini index 5e7bd501d60..7a1cec43997 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -640,6 +640,9 @@ # limit number of api_keys per Org. ; org_api_key = 10 +# limit number of alerts per Org. +;org_alert_rule = 100 + # limit number of orgs a user can create. ; user_org = 10 @@ -658,6 +661,9 @@ # global limit on number of logged in users. ; global_session = -1 +# global limit of alerts +;global_alert_rule = -1 + #################################### Alerting ############################ [alerting] # Disable alerting engine & UI features diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 21736b59de5..704dedcef67 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -1021,6 +1021,10 @@ Limit the number of data sources allowed per organization. Default is 10. Limit the number of API keys that can be entered per organization. Default is 10. +### org_alert_rule + +Limit the number of alert rules that can be entered per organization. Default is 100. + ### user_org Limit the number of organizations a user can create. Default is 10. @@ -1045,6 +1049,10 @@ Sets global limit of API keys that can be entered. Default is -1 (unlimited). Sets a global limit on number of users that can be logged in at one time. Default is -1 (unlimited). +### global_alert_rule + +Sets a global limit on number of alert rules that can be created. Default is -1 (unlimited). +
## [alerting] diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go index be0fbe60a1e..472be504285 100644 --- a/pkg/middleware/quota_test.go +++ b/pkg/middleware/quota_test.go @@ -208,6 +208,61 @@ func TestMiddlewareQuota(t *testing.T) { cfg.Quota.Org.Dashboard = quotaUsed cfg.Quota.Enabled = false }) + + middlewareScenario(t, "org alert quota reached and ngalert enabled", func(t *testing.T, sc *scenarioContext) { + setUp(sc) + + quotaHandler := getQuotaHandler(sc, "alert_rule") + sc.m.Get("/alert_rule", quotaHandler, sc.defaultHandler) + sc.fakeReq("GET", "/alert_rule").exec() + assert.Equal(t, 403, sc.resp.Code) + }, func(cfg *setting.Cfg) { + configure(cfg) + + cfg.FeatureToggles = map[string]bool{"ngalert": true} + cfg.Quota.Org.AlertRule = quotaUsed + }) + + middlewareScenario(t, "org alert quota not reached and ngalert enabled", func(t *testing.T, sc *scenarioContext) { + setUp(sc) + + quotaHandler := getQuotaHandler(sc, "alert_rule") + sc.m.Get("/alert_rule", quotaHandler, sc.defaultHandler) + sc.fakeReq("GET", "/alert_rule").exec() + assert.Equal(t, 200, sc.resp.Code) + }, func(cfg *setting.Cfg) { + configure(cfg) + + cfg.FeatureToggles = map[string]bool{"ngalert": true} + cfg.Quota.Org.AlertRule = quotaUsed + 1 + }) + + middlewareScenario(t, "org alert quota reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { + // this scenario can only happen if the feature was enabled and later disabled + setUp(sc) + + quotaHandler := getQuotaHandler(sc, "alert_rule") + sc.m.Get("/alert_rule", quotaHandler, sc.defaultHandler) + sc.fakeReq("GET", "/alert_rule").exec() + assert.Equal(t, 403, sc.resp.Code) + }, func(cfg *setting.Cfg) { + configure(cfg) + + cfg.Quota.Org.AlertRule = quotaUsed + }) + + middlewareScenario(t, "org alert quota not reached but ngalert disabled", func(t *testing.T, sc *scenarioContext) { + setUp(sc) + + quotaHandler := getQuotaHandler(sc, "alert_rule") + sc.m.Get("/alert_rule", quotaHandler, sc.defaultHandler) + sc.fakeReq("GET", "/alert_rule").exec() + assert.Equal(t, 200, sc.resp.Code) + }, func(cfg *setting.Cfg) { + configure(cfg) + + cfg.Quota.Org.AlertRule = quotaUsed + 1 + }) }) } @@ -230,6 +285,7 @@ func configure(cfg *setting.Cfg) { Dashboard: 5, DataSource: 5, ApiKey: 5, + AlertRule: 5, }, User: &setting.UserQuota{ Org: 5, @@ -241,6 +297,7 @@ func configure(cfg *setting.Cfg) { DataSource: 5, ApiKey: 5, Session: 5, + AlertRule: 5, }, } } diff --git a/pkg/models/quotas.go b/pkg/models/quotas.go index 43b28b9c3e6..864a0f57d4c 100644 --- a/pkg/models/quotas.go +++ b/pkg/models/quotas.go @@ -44,33 +44,38 @@ type GlobalQuotaDTO struct { } type GetOrgQuotaByTargetQuery struct { - Target string - OrgId int64 - Default int64 - Result *OrgQuotaDTO + Target string + OrgId int64 + Default int64 + IsNgAlertEnabled bool + Result *OrgQuotaDTO } type GetOrgQuotasQuery struct { - OrgId int64 - Result []*OrgQuotaDTO + OrgId int64 + IsNgAlertEnabled bool + Result []*OrgQuotaDTO } type GetUserQuotaByTargetQuery struct { - Target string - UserId int64 - Default int64 - Result *UserQuotaDTO + Target string + UserId int64 + Default int64 + IsNgAlertEnabled bool + Result *UserQuotaDTO } type GetUserQuotasQuery struct { - UserId int64 - Result []*UserQuotaDTO + UserId int64 + IsNgAlertEnabled bool + Result []*UserQuotaDTO } type GetGlobalQuotaByTargetQuery struct { - Target string - Default int64 - Result *GlobalQuotaDTO + Target string + Default int64 + IsNgAlertEnabled bool + Result *GlobalQuotaDTO } type UpdateOrgQuotaCmd struct { diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index f3a3ec1d974..9e24ef7aa93 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -3,6 +3,8 @@ package api import ( "time" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/state" @@ -41,6 +43,7 @@ type API struct { DatasourceCache datasources.CacheService RouteRegister routing.RouteRegister DataService *tsdb.Service + QuotaService *quota.QuotaService Schedule schedule.ScheduleService RuleStore store.RuleStore InstanceStore store.InstanceStore @@ -73,7 +76,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.Metrics) { api.RegisterRulerApiEndpoints(NewForkedRuler( api.DatasourceCache, NewLotexRuler(proxy, logger), - RulerSrv{DatasourceCache: api.DatasourceCache, manager: api.StateManager, store: api.RuleStore, log: logger}, + RulerSrv{DatasourceCache: api.DatasourceCache, QuotaService: api.QuotaService, manager: api.StateManager, store: api.RuleStore, log: logger}, ), m) api.RegisterTestingApiEndpoints(TestingApiSrv{ AlertingProxy: proxy, diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 8607666ba08..e79a280ca94 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/quota" coreapi "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/response" @@ -23,6 +24,7 @@ import ( type RulerSrv struct { store store.RuleStore DatasourceCache datasources.CacheService + QuotaService *quota.QuotaService manager *state.Manager log log.Logger } @@ -209,8 +211,18 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf return toNamespaceErrorResponse(err) } - // TODO check permissions - // TODO check quota + // quotas are checked in advanced + // that is acceptable under the assumption that there will be only one alert rule under the rule group + // alternatively we should check the quotas after the rule group update + // and rollback the transaction in case of violation + limitReached, err := srv.QuotaService.QuotaReached(c, "alert_rule") + if err != nil { + return response.Error(http.StatusInternalServerError, "failed to get quota", err) + } + if limitReached { + return response.Error(http.StatusForbidden, "quota reached", nil) + } + // TODO validate UID uniqueness in the payload //TODO: Should this belong in alerting-api? diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index f9423051634..1172e8ad828 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/grafana/grafana/pkg/services/quota" + "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/state" @@ -46,6 +48,7 @@ type AlertNG struct { DataService *tsdb.Service `inject:""` Alertmanager *notifier.Alertmanager `inject:""` DataProxy *datasourceproxy.DatasourceProxyService `inject:""` + QuotaService *quota.QuotaService `inject:""` Metrics *metrics.Metrics `inject:""` Log log.Logger schedule schedule.ScheduleService @@ -83,6 +86,7 @@ func (ng *AlertNG) Init() error { DataService: ng.DataService, Schedule: ng.schedule, DataProxy: ng.DataProxy, + QuotaService: ng.QuotaService, InstanceStore: store, RuleStore: store, AlertingStore: store, diff --git a/pkg/services/quota/quota.go b/pkg/services/quota/quota.go index 7b9b49c074b..5440ef9e62a 100644 --- a/pkg/services/quota/quota.go +++ b/pkg/services/quota/quota.go @@ -64,7 +64,7 @@ func (qs *QuotaService) QuotaReached(c *models.ReqContext, target string) (bool, } continue } - query := models.GetGlobalQuotaByTargetQuery{Target: scope.Target} + query := models.GetGlobalQuotaByTargetQuery{Target: scope.Target, IsNgAlertEnabled: qs.Cfg.IsNgAlertEnabled()} if err := bus.Dispatch(&query); err != nil { return true, err } @@ -75,7 +75,7 @@ func (qs *QuotaService) QuotaReached(c *models.ReqContext, target string) (bool, if !c.IsSignedIn { continue } - query := models.GetOrgQuotaByTargetQuery{OrgId: c.OrgId, Target: scope.Target, Default: scope.DefaultLimit} + query := models.GetOrgQuotaByTargetQuery{OrgId: c.OrgId, Target: scope.Target, Default: scope.DefaultLimit, IsNgAlertEnabled: qs.Cfg.IsNgAlertEnabled()} if err := bus.Dispatch(&query); err != nil { return true, err } @@ -93,7 +93,7 @@ func (qs *QuotaService) QuotaReached(c *models.ReqContext, target string) (bool, if !c.IsSignedIn || c.UserId == 0 { continue } - query := models.GetUserQuotaByTargetQuery{UserId: c.UserId, Target: scope.Target, Default: scope.DefaultLimit} + query := models.GetUserQuotaByTargetQuery{UserId: c.UserId, Target: scope.Target, Default: scope.DefaultLimit, IsNgAlertEnabled: qs.Cfg.IsNgAlertEnabled()} if err := bus.Dispatch(&query); err != nil { return true, err } @@ -151,6 +151,12 @@ func (qs *QuotaService) getQuotaScopes(target string) ([]models.QuotaScope, erro models.QuotaScope{Name: "global", Target: target, DefaultLimit: qs.Cfg.Quota.Global.Session}, ) return scopes, nil + case "alert_rule": // target need to match the respective database name + scopes = append(scopes, + models.QuotaScope{Name: "global", Target: target, DefaultLimit: qs.Cfg.Quota.Global.AlertRule}, + models.QuotaScope{Name: "org", Target: target, DefaultLimit: qs.Cfg.Quota.Org.AlertRule}, + ) + return scopes, nil default: return scopes, ErrInvalidQuotaTarget } diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index ed8c2c72d4a..1db991c8fd2 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -9,6 +9,8 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +const ALERT_RULE_TARGET = "alert_rule" + func init() { bus.AddHandler("sql", GetOrgQuotaByTarget) bus.AddHandler("sql", GetOrgQuotas) @@ -35,18 +37,22 @@ func GetOrgQuotaByTarget(query *models.GetOrgQuotaByTargetQuery) error { quota.Limit = query.Default } - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(query.Target)) - resp := make([]*targetCount, 0) - if err := x.SQL(rawSQL, query.OrgId).Find(&resp); err != nil { - return err + var used int64 + if query.Target != ALERT_RULE_TARGET || query.IsNgAlertEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(query.Target)) + resp := make([]*targetCount, 0) + if err := x.SQL(rawSQL, query.OrgId).Find(&resp); err != nil { + return err + } + used = resp[0].Count } query.Result = &models.OrgQuotaDTO{ Target: query.Target, Limit: quota.Limit, OrgId: query.OrgId, - Used: resp[0].Count, + Used: used, } return nil @@ -78,17 +84,21 @@ func GetOrgQuotas(query *models.GetOrgQuotasQuery) error { result := make([]*models.OrgQuotaDTO, len(quotas)) for i, q := range quotas { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(q.Target)) - resp := make([]*targetCount, 0) - if err := x.SQL(rawSQL, q.OrgId).Find(&resp); err != nil { - return err + var used int64 + if q.Target != ALERT_RULE_TARGET || query.IsNgAlertEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where org_id=?", dialect.Quote(q.Target)) + resp := make([]*targetCount, 0) + if err := x.SQL(rawSQL, q.OrgId).Find(&resp); err != nil { + return err + } + used = resp[0].Count } result[i] = &models.OrgQuotaDTO{ Target: q.Target, Limit: q.Limit, OrgId: q.OrgId, - Used: resp[0].Count, + Used: used, } } query.Result = result @@ -138,18 +148,22 @@ func GetUserQuotaByTarget(query *models.GetUserQuotaByTargetQuery) error { quota.Limit = query.Default } - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(query.Target)) - resp := make([]*targetCount, 0) - if err := x.SQL(rawSQL, query.UserId).Find(&resp); err != nil { - return err + var used int64 + if query.Target != ALERT_RULE_TARGET || query.IsNgAlertEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(query.Target)) + resp := make([]*targetCount, 0) + if err := x.SQL(rawSQL, query.UserId).Find(&resp); err != nil { + return err + } + used = resp[0].Count } query.Result = &models.UserQuotaDTO{ Target: query.Target, Limit: quota.Limit, UserId: query.UserId, - Used: resp[0].Count, + Used: used, } return nil @@ -181,17 +195,21 @@ func GetUserQuotas(query *models.GetUserQuotasQuery) error { result := make([]*models.UserQuotaDTO, len(quotas)) for i, q := range quotas { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(q.Target)) - resp := make([]*targetCount, 0) - if err := x.SQL(rawSQL, q.UserId).Find(&resp); err != nil { - return err + var used int64 + if q.Target != ALERT_RULE_TARGET || query.IsNgAlertEnabled { + // get quota used. + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s where user_id=?", dialect.Quote(q.Target)) + resp := make([]*targetCount, 0) + if err := x.SQL(rawSQL, q.UserId).Find(&resp); err != nil { + return err + } + used = resp[0].Count } result[i] = &models.UserQuotaDTO{ Target: q.Target, Limit: q.Limit, UserId: q.UserId, - Used: resp[0].Count, + Used: used, } } query.Result = result @@ -230,17 +248,22 @@ func UpdateUserQuota(cmd *models.UpdateUserQuotaCmd) error { } func GetGlobalQuotaByTarget(query *models.GetGlobalQuotaByTargetQuery) error { - // get quota used. - rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s", dialect.Quote(query.Target)) - resp := make([]*targetCount, 0) - if err := x.SQL(rawSQL).Find(&resp); err != nil { - return err + var used int64 + if query.Target != ALERT_RULE_TARGET || query.IsNgAlertEnabled { + // get quota used. + + rawSQL := fmt.Sprintf("SELECT COUNT(*) as count from %s", dialect.Quote(query.Target)) + resp := make([]*targetCount, 0) + if err := x.SQL(rawSQL).Find(&resp); err != nil { + return err + } + used = resp[0].Count } query.Result = &models.GlobalQuotaDTO{ Target: query.Target, Limit: query.Default, - Used: resp[0].Count, + Used: used, } return nil diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go index 0fefb9ea2d4..d1314a97056 100644 --- a/pkg/services/sqlstore/quota_test.go +++ b/pkg/services/sqlstore/quota_test.go @@ -24,6 +24,7 @@ func TestQuotaCommandsAndQueries(t *testing.T) { Dashboard: 5, DataSource: 5, ApiKey: 5, + AlertRule: 5, }, User: &setting.UserQuota{ Org: 5, @@ -35,6 +36,7 @@ func TestQuotaCommandsAndQueries(t *testing.T) { DataSource: 5, ApiKey: 5, Session: 5, + AlertRule: 5, }, } @@ -87,12 +89,19 @@ func TestQuotaCommandsAndQueries(t *testing.T) { So(err, ShouldBeNil) So(query.Result.Used, ShouldEqual, 0) }) + Convey("Should be able to get zero used org alert quota when table does not exist (ngalert is not enabled - default case)", func() { + query := models.GetOrgQuotaByTargetQuery{OrgId: 2, Target: "alert", Default: 11} + err = GetOrgQuotaByTarget(&query) + + So(err, ShouldBeNil) + So(query.Result.Used, ShouldEqual, 0) + }) Convey("Should be able to quota list for org", func() { query := models.GetOrgQuotasQuery{OrgId: orgId} err = GetOrgQuotas(&query) So(err, ShouldBeNil) - So(len(query.Result), ShouldEqual, 4) + So(len(query.Result), ShouldEqual, 5) for _, res := range query.Result { limit := 5 // default quota limit used := 0 @@ -169,6 +178,14 @@ func TestQuotaCommandsAndQueries(t *testing.T) { So(query.Result.Limit, ShouldEqual, 5) So(query.Result.Used, ShouldEqual, 1) }) + Convey("Should be able to get zero used global alert quota when table does not exist (ngalert is not enabled - default case)", func() { + query := models.GetGlobalQuotaByTargetQuery{Target: "alert_rule", Default: 5} + err = GetGlobalQuotaByTarget(&query) + So(err, ShouldBeNil) + + So(query.Result.Limit, ShouldEqual, 5) + So(query.Result.Used, ShouldEqual, 0) + }) // related: https://github.com/grafana/grafana/issues/14342 Convey("Should org quota updating is successful even if it called multiple time", func() { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index bf36b35a010..7b31ebb7926 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -158,7 +158,6 @@ func (ss *SQLStore) ensureMainOrgAndAdminUser() error { // ensure admin user if !ss.Cfg.DisableInitAdminCreation { - ss.log.Debug("Creating default admin user") ss.log.Debug("Creating default admin user") if _, err := ss.createUser(ctx, sess, userCreationArgs{ Login: ss.Cfg.AdminUser, diff --git a/pkg/setting/setting_quota.go b/pkg/setting/setting_quota.go index be562a4c0e2..d62e5727d5c 100644 --- a/pkg/setting/setting_quota.go +++ b/pkg/setting/setting_quota.go @@ -9,6 +9,7 @@ type OrgQuota struct { DataSource int64 `target:"data_source"` Dashboard int64 `target:"dashboard"` ApiKey int64 `target:"api_key"` + AlertRule int64 `target:"alert_rule"` } type UserQuota struct { @@ -22,6 +23,7 @@ type GlobalQuota struct { Dashboard int64 `target:"dashboard"` ApiKey int64 `target:"api_key"` Session int64 `target:"-"` + AlertRule int64 `target:"alert_rule"` } func (q *OrgQuota) ToMap() map[string]int64 { @@ -64,12 +66,19 @@ func (cfg *Cfg) readQuotaSettings() { quota := cfg.Raw.Section("quota") Quota.Enabled = quota.Key("enabled").MustBool(false) + var alertOrgQuota int64 + var alertGlobalQuota int64 + if cfg.IsNgAlertEnabled() { + alertOrgQuota = quota.Key("org_alert_rule").MustInt64(100) + alertGlobalQuota = quota.Key("global_alert_rule").MustInt64(-1) + } // per ORG Limits Quota.Org = &OrgQuota{ User: quota.Key("org_user").MustInt64(10), DataSource: quota.Key("org_data_source").MustInt64(10), Dashboard: quota.Key("org_dashboard").MustInt64(10), ApiKey: quota.Key("org_api_key").MustInt64(10), + AlertRule: alertOrgQuota, } // per User limits @@ -85,6 +94,7 @@ func (cfg *Cfg) readQuotaSettings() { Dashboard: quota.Key("global_dashboard").MustInt64(-1), ApiKey: quota.Key("global_api_key").MustInt64(-1), Session: quota.Key("global_session").MustInt64(-1), + AlertRule: alertGlobalQuota, } cfg.Quota = Quota diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index ccc2018330b..c7ea7a2f928 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -2,6 +2,7 @@ package alerting import ( "bytes" + "context" "encoding/json" "fmt" "io/ioutil" @@ -10,6 +11,8 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/bus" + "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -26,15 +29,52 @@ import ( func TestAlertAndGroupsQuery(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, - AnonymousUserRole: models.ROLE_EDITOR, + DisableAnonymous: true, }) store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + // unauthenticated request to get the alerts should fail + { + alertsURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.JSONEq(t, `{"message": "Unauthorized"}`, string(b)) + } + + // Create a user to make authenticated requests + require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) + + // invalid credentials request to get the alerts should fail + { + alertsURL := fmt.Sprintf("http://grafana:invalid@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(alertsURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.JSONEq(t, `{"error": "invalid username or password","message": "invalid username or password"}`, string(b)) + } + // When there are no alerts available, it returns an empty list. { - alertsURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) // nolint:gosec resp, err := http.Get(alertsURL) require.NoError(t, err) @@ -50,7 +90,7 @@ func TestAlertAndGroupsQuery(t *testing.T) { // When are there no alerts available, it returns an empty list of groups. { - alertsURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/api/v2/alerts/groups", grafanaListedAddr) + alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts/groups", grafanaListedAddr) // nolint:gosec resp, err := http.Get(alertsURL) require.NoError(t, err) @@ -106,7 +146,7 @@ func TestAlertAndGroupsQuery(t *testing.T) { err = enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) t.Cleanup(func() { @@ -119,7 +159,7 @@ func TestAlertAndGroupsQuery(t *testing.T) { // Eventually, we'll get an alert with its state being active. { - alertsURL := fmt.Sprintf("http://%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) + alertsURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/api/v2/alerts", grafanaListedAddr) // nolint:gosec require.Eventually(t, func() bool { resp, err := http.Get(alertsURL) @@ -150,11 +190,18 @@ func TestAlertRuleCRUD(t *testing.T) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, - AnonymousUserRole: models.ROLE_EDITOR, + EnableQuota: true, + DisableAnonymous: true, }) + store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + err := createUser(t, store, models.ROLE_EDITOR, "grafana", "password") + require.NoError(t, err) + // Create the namespace we'll save our alerts to. require.NoError(t, createFolder(t, store, 0, "default")) @@ -229,7 +276,7 @@ func TestAlertRuleCRUD(t *testing.T) { Annotations: map[string]string{"annotation1": "val1"}, }, GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: getLongString(ngstore.AlertRuleMaxTitleLength + 1), + Title: getLongString(t, ngstore.AlertRuleMaxTitleLength+1), Condition: "A", Data: []ngmodels.AlertQuery{ { @@ -251,7 +298,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, { desc: "alert rule with too long rulegroup", - rulegroup: getLongString(ngstore.AlertRuleMaxTitleLength + 1), + rulegroup: getLongString(t, ngstore.AlertRuleMaxTitleLength+1), rule: apimodels.PostableExtendedRuleNode{ ApiRuleNode: &apimodels.ApiRuleNode{ For: interval, @@ -386,7 +433,7 @@ func TestAlertRuleCRUD(t *testing.T) { err := enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) require.NoError(t, err) @@ -466,7 +513,7 @@ func TestAlertRuleCRUD(t *testing.T) { err := enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) require.NoError(t, err) @@ -483,7 +530,7 @@ func TestAlertRuleCRUD(t *testing.T) { // With the rules created, let's make sure that rule definition is stored correctly. { - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err := http.Get(u) require.NoError(t, err) @@ -523,7 +570,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, "grafana_alert":{ "id":1, - "orgId":2, + "orgId":1, "title":"AlwaysFiring", "condition":"A", "data":[ @@ -558,7 +605,7 @@ func TestAlertRuleCRUD(t *testing.T) { "expr":"", "grafana_alert":{ "id":2, - "orgId":2, + "orgId":1, "title":"AlwaysFiringButSilenced", "condition":"A", "data":[ @@ -645,7 +692,7 @@ func TestAlertRuleCRUD(t *testing.T) { err = enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) require.NoError(t, err) @@ -660,7 +707,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.JSONEq(t, `{"error":"failed to get alert rule unknown: could not find alert rule", "message": "failed to update rule group"}`, string(b)) // let's make sure that rule definitions are not affected by the failed POST request. - u = fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err = http.Get(u) require.NoError(t, err) @@ -729,7 +776,7 @@ func TestAlertRuleCRUD(t *testing.T) { err = enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) require.NoError(t, err) @@ -744,7 +791,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) // let's make sure that rule definitions are updated correctly. - u = fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u = fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err = http.Get(u) require.NoError(t, err) @@ -782,7 +829,7 @@ func TestAlertRuleCRUD(t *testing.T) { }, "grafana_alert":{ "id":1, - "orgId":2, + "orgId":1, "title":"AlwaysNormal", "condition":"A", "data":[ @@ -823,7 +870,7 @@ func TestAlertRuleCRUD(t *testing.T) { // Finally, make sure we can delete it. { t.Run("fail if he rule group name does not exists", func(t *testing.T) { - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default/groupnotexist", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default/groupnotexist", grafanaListedAddr) req, err := http.NewRequest(http.MethodDelete, u, nil) require.NoError(t, err) resp, err := client.Do(req) @@ -840,7 +887,7 @@ func TestAlertRuleCRUD(t *testing.T) { }) t.Run("succeed if the rule group name does exist", func(t *testing.T) { - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default/arulegroup", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default/arulegroup", grafanaListedAddr) req, err := http.NewRequest(http.MethodDelete, u, nil) require.NoError(t, err) resp, err := client.Do(req) @@ -856,6 +903,124 @@ func TestAlertRuleCRUD(t *testing.T) { require.JSONEq(t, `{"message":"rule group deleted"}`, string(b)) }) } +} + +func TestQuota(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + EnableQuota: true, + DisableAnonymous: true, + }) + + store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + + // Create the namespace we'll save our alerts to. + require.NoError(t, createFolder(t, store, 0, "default")) + + // Create a user to make authenticated requests + require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) + + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + + // check quota limits + t.Run("when quota limit exceed", func(t *testing.T) { + // get existing org quota + query := models.GetOrgQuotaByTargetQuery{OrgId: 1, Target: "alert_rule"} + err = sqlstore.GetOrgQuotaByTarget(&query) + require.NoError(t, err) + used := query.Result.Used + limit := query.Result.Limit + + // set org quota limit to equal used + orgCmd := models.UpdateOrgQuotaCmd{ + OrgId: 1, + Target: "alert_rule", + Limit: used, + } + err := sqlstore.UpdateOrgQuota(&orgCmd) + require.NoError(t, err) + + t.Cleanup(func() { + // reset org quota to original value + orgCmd := models.UpdateOrgQuotaCmd{ + OrgId: 1, + Target: "alert_rule", + Limit: limit, + } + err := sqlstore.UpdateOrgQuota(&orgCmd) + require.NoError(t, err) + }) + + // try to create an alert rule + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Interval: interval, + Rules: []apimodels.PostableExtendedRuleNode{ + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: "One more alert rule", + Condition: "A", + Data: []ngmodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: ngmodels.RelativeTimeRange{ + From: ngmodels.Duration(time.Duration(5) * time.Hour), + To: ngmodels.Duration(time.Duration(3) * time.Hour), + }, + Model: json.RawMessage(`{ + "datasourceUid": "-100", + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err = enc.Encode(&rules) + require.NoError(t, err) + + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Post(u, "application/json", &buf) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + require.JSONEq(t, `{"message":"quota reached"}`, string(b)) + }) +} + +func TestEval(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + EnableQuota: true, + DisableAnonymous: true, + }) + + store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + + require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) + + // Create the namespace we'll save our alerts to. + require.NoError(t, createFolder(t, store, 0, "default")) // test eval conditions testCases := []struct { @@ -1022,7 +1187,7 @@ func TestAlertRuleCRUD(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - u := fmt.Sprintf("http://%s/api/v1/rule/test/grafana", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/v1/rule/test/grafana", grafanaListedAddr) r := strings.NewReader(tc.payload) // nolint:gosec resp, err := http.Post(u, "application/json", r) @@ -1178,7 +1343,7 @@ func TestAlertRuleCRUD(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - u := fmt.Sprintf("http://%s/api/v1/eval", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/v1/eval", grafanaListedAddr) r := strings.NewReader(tc.payload) // nolint:gosec resp, err := http.Post(u, "application/json", r) @@ -1202,7 +1367,7 @@ func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folder t.Helper() cmd := models.SaveDashboardCommand{ - OrgId: 2, // This is the orgID of the anonymous user. + OrgId: 1, // default organisation FolderId: folderID, IsFolder: true, Dashboard: simplejson.NewFromAny(map[string]interface{}{ @@ -1244,7 +1409,21 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st return string(json), m } -func getLongString(n int) string { +func createUser(t *testing.T, store *sqlstore.SQLStore, role models.RoleType, username, password string) error { + t.Helper() + + cmd := models.CreateUserCommand{ + Login: username, + Password: password, + DefaultOrgRole: string(role), + } + _, err := store.CreateUser(context.Background(), cmd) + return err +} + +func getLongString(t *testing.T, n int) string { + t.Helper() + b := make([]rune, n) for i := range b { b[i] = 'a' diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 027c531cbce..81b49aad636 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -21,18 +22,23 @@ import ( func TestPrometheusRules(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, - AnonymousUserRole: models.ROLE_EDITOR, + DisableAnonymous: true, }) store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) - // Create the namespace we'll save our alerts to. + // Create the namespace under default organisation (orgID = 1) where we'll save our alerts to. require.NoError(t, createFolder(t, store, 0, "default")) + // Create a user to make authenticated requests + require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) + interval, err := model.ParseDuration("10s") require.NoError(t, err) - // When we have no alerting rules, it returns an empty list. + // an unauthenticated request to get rules should fail { promRulesURL := fmt.Sprintf("http://%s/api/prometheus/grafana/api/v1/rules", grafanaListedAddr) // nolint:gosec @@ -42,6 +48,20 @@ func TestPrometheusRules(t *testing.T) { err := resp.Body.Close() require.NoError(t, err) }) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) + } + + // When we have no alerting rules, it returns an empty list. + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) @@ -109,7 +129,7 @@ func TestPrometheusRules(t *testing.T) { err := enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) require.NoError(t, err) @@ -126,7 +146,7 @@ func TestPrometheusRules(t *testing.T) { // Now, let's see how this looks like. { - promRulesURL := fmt.Sprintf("http://%s/api/prometheus/grafana/api/v1/rules", grafanaListedAddr) + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules", grafanaListedAddr) // nolint:gosec resp, err := http.Get(promRulesURL) require.NoError(t, err) @@ -181,7 +201,7 @@ func TestPrometheusRules(t *testing.T) { } { - promRulesURL := fmt.Sprintf("http://%s/api/prometheus/grafana/api/v1/rules", grafanaListedAddr) + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules", grafanaListedAddr) // nolint:gosec require.Eventually(t, func() bool { resp, err := http.Get(promRulesURL) diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 6b4750e88a8..cdac017299f 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -22,11 +23,16 @@ func TestAlertRulePermissions(t *testing.T) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, - AnonymousUserRole: models.ROLE_EDITOR, + DisableAnonymous: true, }) store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + // Create a user to make authenticated requests + require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) + // Create the namespace we'll save our alerts to. require.NoError(t, createFolder(t, store, 0, "folder1")) @@ -41,7 +47,7 @@ func TestAlertRulePermissions(t *testing.T) { // With the rules created, let's make sure that rule definitions are stored. { - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) // nolint:gosec resp, err := http.Get(u) require.NoError(t, err) @@ -73,7 +79,7 @@ func TestAlertRulePermissions(t *testing.T) { }, "grafana_alert":{ "id":1, - "orgId":2, + "orgId":1, "title":"rule under folder folder1", "condition":"A", "data":[ @@ -123,7 +129,7 @@ func TestAlertRulePermissions(t *testing.T) { }, "grafana_alert":{ "id":2, - "orgId":2, + "orgId":1, "title":"rule under folder folder2", "condition":"A", "data":[ @@ -195,7 +201,7 @@ func TestAlertRulePermissions(t *testing.T) { }, "grafana_alert":{ "id":1, - "orgId":2, + "orgId":1, "title":"rule under folder folder1", "condition":"A", "data":[ @@ -276,7 +282,7 @@ func createRule(t *testing.T, grafanaListedAddr string, folder string) { err = enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/%s", grafanaListedAddr, folder) + u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/%s", grafanaListedAddr, folder) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) require.NoError(t, err) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 8d25914490b..106f3dc05b5 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -219,6 +219,18 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { _, err = anonSect.NewKey("org_role", string(o.AnonymousUserRole)) require.NoError(t, err) } + if o.EnableQuota { + quotaSection, err := cfg.NewSection("quota") + require.NoError(t, err) + _, err = quotaSection.NewKey("enabled", "true") + require.NoError(t, err) + } + if o.DisableAnonymous { + anonSect, err := cfg.GetSection("auth.anonymous") + require.NoError(t, err) + _, err = anonSect.NewKey("enabled", "false") + require.NoError(t, err) + } } cfgPath := filepath.Join(cfgDir, "test.ini") @@ -235,4 +247,6 @@ type GrafanaOpts struct { EnableCSP bool EnableFeatureToggles []string AnonymousUserRole models.RoleType + EnableQuota bool + DisableAnonymous bool }