Alerting: Update provisioning API to support regular permissions (#77007)

* allow users with regular actions access provisioning API paths
* update methods that read rules
skip new authorization logic if user CanReadAllRules to avoid performance impact on file-provisioning
update all methods to accept identity.Requester that contains all permissions and is required by access control.

* create deltas for single rul e 

* update modify methods
skip new authorization logic if user CanWriteAllRules to avoid performance impact on file-provisioning
update all methods to accept identity.Requester that contains all permissions and is required by access control.

* implement RuleAccessControlService in provisioning

* update file provisioning user to have all permissions to bypass authz

* update provisioning API to return errutil errors correctly

---------

Co-authored-by: Alexander Weaver <weaver.alex.d@gmail.com>
This commit is contained in:
Yuri Tseretyan 2024-03-22 15:37:10 -04:00 committed by GitHub
parent 0b4830ccfd
commit b9abb8cabb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2039 additions and 108 deletions

View File

@ -58,7 +58,7 @@ func (s *FakeRuleService) HasAccessToRuleGroup(ctx context.Context, user identit
}
func (s *FakeRuleService) AuthorizeAccessToRuleGroup(ctx context.Context, user identity.Requester, rules models.RulesGroup) error {
s.Calls = append(s.Calls, Call{"AuthorizeAccessToRuleGroup", []interface{}{ctx, user, rules}})
s.Calls = append(s.Calls, Call{"AuthorizeRuleGroupRead", []interface{}{ctx, user, rules}})
if s.AuthorizeAccessToRuleGroupFunc != nil {
return s.AuthorizeAccessToRuleGroupFunc(ctx, user, rules)
}
@ -66,7 +66,7 @@ func (s *FakeRuleService) AuthorizeAccessToRuleGroup(ctx context.Context, user i
}
func (s *FakeRuleService) AuthorizeRuleChanges(ctx context.Context, user identity.Requester, change *store.GroupDelta) error {
s.Calls = append(s.Calls, Call{"AuthorizeRuleChanges", []interface{}{ctx, user, change}})
s.Calls = append(s.Calls, Call{"AuthorizeRuleGroupWrite", []interface{}{ctx, user, change}})
if s.AuthorizeRuleChangesFunc != nil {
return s.AuthorizeRuleChangesFunc(ctx, user, change)
}

View File

@ -311,7 +311,7 @@ func (srv *ProvisioningSrv) RouteDeleteMuteTiming(c *contextmodel.ReqContext, na
func (srv *ProvisioningSrv) RouteGetAlertRules(c *contextmodel.ReqContext) response.Response {
rules, provenances, err := srv.alertRules.GetAlertRules(c.Req.Context(), c.SignedInUser)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "", err)
}
return response.JSON(http.StatusOK, ProvisionedAlertRuleFromAlertRules(rules, provenances))
}
@ -322,7 +322,7 @@ func (srv *ProvisioningSrv) RouteRouteGetAlertRule(c *contextmodel.ReqContext, U
if errors.Is(err, alerting_models.ErrAlertRuleNotFound) {
return response.Empty(http.StatusNotFound)
}
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule by UID", err)
}
return response.JSON(http.StatusOK, ProvisionedAlertRuleFromAlertRule(rule, provenace))
}
@ -348,7 +348,7 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *contextmodel.ReqContext, ar de
if errors.Is(err, alerting_models.ErrQuotaReached) {
return ErrResp(http.StatusForbidden, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "", err)
}
resp := ProvisionedAlertRuleFromAlertRule(createdAlertRule, alerting_models.Provenance(provenance))
@ -377,7 +377,7 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *contextmodel.ReqContext, ar def
if errors.Is(err, store.ErrOptimisticLock) {
return ErrResp(http.StatusConflict, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "", err)
}
resp := ProvisionedAlertRuleFromAlertRule(updatedAlertRule, alerting_models.Provenance(provenance))
@ -388,7 +388,7 @@ func (srv *ProvisioningSrv) RouteDeleteAlertRule(c *contextmodel.ReqContext, UID
provenance := determineProvenance(c)
err := srv.alertRules.DeleteAlertRule(c.Req.Context(), c.SignedInUser, UID, alerting_models.Provenance(provenance))
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "", err)
}
return response.JSON(http.StatusNoContent, "")
}
@ -424,7 +424,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRulesExport(c *contextmodel.ReqContext)
groupsWithTitle, err := srv.alertRules.GetAlertGroupsWithFolderTitle(c.Req.Context(), c.SignedInUser, folderUIDs)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to get alert rules")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get alert rules", err)
}
if len(groupsWithTitle) == 0 {
return response.Empty(http.StatusNotFound)
@ -432,7 +432,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRulesExport(c *contextmodel.ReqContext)
e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle(groupsWithTitle)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to create alerting file export", err)
}
return exportResponse(c, e)
@ -447,7 +447,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRuleGroupExport(c *contextmodel.ReqCont
e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle([]alerting_models.AlertRuleGroupWithFolderTitle{g})
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to create alerting file export", err)
}
return exportResponse(c, e)
@ -460,7 +460,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRuleExport(c *contextmodel.ReqContext,
if errors.Is(err, alerting_models.ErrAlertRuleNotFound) {
return ErrResp(http.StatusNotFound, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get alert rules", err)
}
e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle([]alerting_models.AlertRuleGroupWithFolderTitle{
@ -492,7 +492,7 @@ func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *contextmodel.ReqContext, a
if errors.Is(err, store.ErrOptimisticLock) {
return ErrResp(http.StatusConflict, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "", err)
}
return response.JSON(http.StatusOK, ag)
}

View File

@ -28,6 +28,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
@ -1613,6 +1614,7 @@ type testEnvironment struct {
quotas provisioning.QuotaChecker
prov provisioning.ProvisioningStore
ac *recordingAccessControlFake
rulesAuthz *fakes.FakeRuleService
}
func createTestEnv(t *testing.T, testConfig string) testEnvironment {
@ -1674,6 +1676,8 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment {
ac := &recordingAccessControlFake{}
ruleAuthz := &fakes.FakeRuleService{}
return testEnvironment{
secrets: secretsService,
log: log,
@ -1685,6 +1689,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment {
prov: prov,
quotas: quotas,
ac: ac,
rulesAuthz: ruleAuthz,
}
}
@ -1705,7 +1710,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi
contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, receiverSvc, env.log, env.store),
templates: provisioning.NewTemplateService(env.configs, env.prov, env.xact, env.log),
muteTimings: provisioning.NewMuteTimingService(env.configs, env.prov, env.xact, env.log),
alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.folderService, env.dashboardService, env.quotas, env.xact, 60, 10, 100, env.log, &provisioning.NotificationSettingsValidatorProviderFake{}),
alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.folderService, env.dashboardService, env.quotas, env.xact, 60, 10, 100, env.log, &provisioning.NotificationSettingsValidatorProviderFake{}, env.rulesAuthz),
}
}

View File

@ -223,19 +223,90 @@ func (api *API) authorize(method, path string) web.Handler {
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets), // organization scope
)
case http.MethodGet + "/api/v1/provisioning/alert-rules",
http.MethodGet + "/api/v1/provisioning/alert-rules/export":
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningRead),
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets),
ac.EvalAll( // scopes are enforced in the handler
ac.EvalPermission(ac.ActionAlertingRuleRead),
ac.EvalPermission(dashboards.ActionFoldersRead),
),
)
case http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}",
http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}/export":
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningRead),
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets),
ac.EvalAll(
ac.EvalPermission(ac.ActionAlertingRuleRead),
ac.EvalPermission(dashboards.ActionFoldersRead),
),
)
case http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}",
http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export":
scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":FolderUID"))
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningRead),
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets),
ac.EvalAll(
ac.EvalPermission(ac.ActionAlertingRuleRead, scope),
ac.EvalPermission(dashboards.ActionFoldersRead, scope),
),
)
case http.MethodGet + "/api/v1/provisioning/policies",
http.MethodGet + "/api/v1/provisioning/contact-points",
http.MethodGet + "/api/v1/provisioning/templates",
http.MethodGet + "/api/v1/provisioning/templates/{name}",
http.MethodGet + "/api/v1/provisioning/mute-timings",
http.MethodGet + "/api/v1/provisioning/mute-timings/{name}",
http.MethodGet + "/api/v1/provisioning/alert-rules",
http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}",
http.MethodGet + "/api/v1/provisioning/alert-rules/export",
http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}/export",
http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}",
http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export":
eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingProvisioningRead), ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets)) // organization scope
http.MethodGet + "/api/v1/provisioning/mute-timings/{name}":
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningRead),
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets),
)
// Grafana-only Provisioning Write Paths
case http.MethodPost + "/api/v1/provisioning/alert-rules":
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningWrite),
ac.EvalPermission(ac.ActionAlertingRuleCreate), // more granular permissions are enforced by the handler via "authorizeRuleChanges"
)
case http.MethodPut + "/api/v1/provisioning/alert-rules/{UID}":
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningWrite),
ac.EvalPermission(ac.ActionAlertingRuleUpdate), // more granular permissions are enforced by the handler via "authorizeRuleChanges"
)
case http.MethodDelete + "/api/v1/provisioning/alert-rules/{UID}":
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningWrite),
ac.EvalPermission(ac.ActionAlertingRuleDelete), // more granular permissions are enforced by the handler via "authorizeRuleChanges"
)
case http.MethodDelete + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}":
scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":FolderUID"))
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningWrite),
ac.EvalAll(
ac.EvalPermission(ac.ActionAlertingRuleDelete, scope),
ac.EvalPermission(ac.ActionAlertingRuleRead, scope),
ac.EvalPermission(dashboards.ActionFoldersRead, scope),
),
)
case http.MethodPut + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}":
scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":FolderUID"))
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningWrite),
ac.EvalAll(
ac.EvalPermission(ac.ActionAlertingRuleRead, scope),
ac.EvalPermission(dashboards.ActionFoldersRead, scope),
ac.EvalAny( // the exact permissions will be checked after the operations are determined
ac.EvalPermission(ac.ActionAlertingRuleUpdate, scope),
ac.EvalPermission(ac.ActionAlertingRuleCreate, scope),
ac.EvalPermission(ac.ActionAlertingRuleDelete, scope),
),
),
)
case http.MethodPut + "/api/v1/provisioning/policies",
http.MethodDelete + "/api/v1/provisioning/policies",
@ -246,12 +317,7 @@ func (api *API) authorize(method, path string) web.Handler {
http.MethodDelete + "/api/v1/provisioning/templates/{name}",
http.MethodPost + "/api/v1/provisioning/mute-timings",
http.MethodPut + "/api/v1/provisioning/mute-timings/{name}",
http.MethodDelete + "/api/v1/provisioning/mute-timings/{name}",
http.MethodPost + "/api/v1/provisioning/alert-rules",
http.MethodPut + "/api/v1/provisioning/alert-rules/{UID}",
http.MethodDelete + "/api/v1/provisioning/alert-rules/{UID}",
http.MethodPut + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}",
http.MethodDelete + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}":
http.MethodDelete + "/api/v1/provisioning/mute-timings/{name}":
eval = ac.EvalPermission(ac.ActionAlertingProvisioningWrite) // organization scope
case http.MethodGet + "/api/v1/notifications/time-intervals/{name}",
http.MethodGet + "/api/v1/notifications/time-intervals":

View File

@ -5557,7 +5557,7 @@
}
}
},
"summary": "Update the interval of a rule group.",
"summary": "Create or update alert rule group.",
"tags": [
"provisioning"
]

View File

@ -203,7 +203,7 @@ type ProvisionedAlertRule struct {
// swagger:route PUT /v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RoutePutAlertRuleGroup
//
// Update the interval of a rule group.
// Create or update alert rule group.
//
// Consumes:
// - application/json

View File

@ -7706,7 +7706,7 @@
}
}
},
"summary": "Update the interval of a rule group.",
"summary": "Create or update alert rule group.",
"tags": [
"provisioning"
]

View File

@ -2698,7 +2698,7 @@
"provisioning",
"stable"
],
"summary": "Update the interval of a rule group.",
"summary": "Create or update alert rule group.",
"operationId": "RoutePutAlertRuleGroup",
"parameters": [
{

View File

@ -26,6 +26,7 @@ import (
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/api"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
"github.com/grafana/grafana/pkg/services/ngalert/image"
@ -316,7 +317,8 @@ func (ng *AlertNG) init() error {
alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.folderService, ng.dashboardService, ng.QuotaService, ng.store,
int64(ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()),
int64(ng.Cfg.UnifiedAlerting.BaseInterval.Seconds()),
ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, ng.Log, notifier.NewNotificationSettingsValidationService(ng.store))
ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, ng.Log, notifier.NewNotificationSettingsValidationService(ng.store),
ac.NewRuleService(ng.accesscontrol))
ng.api = &api.API{
Cfg: ng.Cfg,

View File

@ -0,0 +1,76 @@
package provisioning
import (
"context"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
)
type RuleAccessControlService interface {
HasAccess(ctx context.Context, user identity.Requester, evaluator ac.Evaluator) (bool, error)
AuthorizeAccessToRuleGroup(ctx context.Context, user identity.Requester, rules models.RulesGroup) error
AuthorizeRuleChanges(ctx context.Context, user identity.Requester, change *store.GroupDelta) error
}
func newRuleAccessControlService(ac RuleAccessControlService) *provisioningRuleAccessControl {
return &provisioningRuleAccessControl{
RuleAccessControlService: ac,
}
}
type provisioningRuleAccessControl struct {
RuleAccessControlService
}
var _ ruleAccessControlService = &provisioningRuleAccessControl{}
// AuthorizeRuleGroupRead authorizes the read access to a group of rules for a user.
// It first checks if the user has permission to read all rules. If yes, it bypasses the authorization.
// If not, it calls the RuleAccessControlService to authorize access to the rule group.
// It returns an error if the authorization fails or if there is an error during permission check.
func (p *provisioningRuleAccessControl) AuthorizeRuleGroupRead(ctx context.Context, user identity.Requester, rules models.RulesGroup) error {
can, err := p.CanReadAllRules(ctx, user)
if err != nil {
return err
}
if !can {
return p.RuleAccessControlService.AuthorizeAccessToRuleGroup(ctx, user, rules)
}
return nil
}
// AuthorizeRuleGroupWrite authorizes the write access to a group of rules for a user.
// It first checks if the user has permission to write all rules. If yes, it bypasses the authorization.
// If not, it calls the RuleAccessControlService to authorize the rule changes.
// It returns an error if the authorization fails or if there is an error during permission check.
func (p *provisioningRuleAccessControl) AuthorizeRuleGroupWrite(ctx context.Context, user identity.Requester, change *store.GroupDelta) error {
can, err := p.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
if !can {
return p.RuleAccessControlService.AuthorizeRuleChanges(ctx, user, change)
}
return nil
}
// CanReadAllRules checks if the user has permission to read all rules.
// It evaluates if the user has either "alert.provisioning:read" or "alert.provisioning.secrets:read" permissions.
// It returns true if the user has the required permissions, otherwise it returns false.
func (p *provisioningRuleAccessControl) CanReadAllRules(ctx context.Context, user identity.Requester) (bool, error) {
return p.HasAccess(ctx, user, ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningRead),
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets),
))
}
// CanWriteAllRules is a method that checks if a user has permission to write all rules.
// It calls the HasAccess method with the provided action "alert.provisioning:write".
// It returns true if the user has permission, false otherwise.
// It returns an error if there is a problem checking the permission.
func (p *provisioningRuleAccessControl) CanWriteAllRules(ctx context.Context, user identity.Requester) (bool, error) {
return p.HasAccess(ctx, user, ac.EvalPermission(ac.ActionAlertingProvisioningWrite))
}

View File

@ -0,0 +1,231 @@
package provisioning
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/rand"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/user"
)
func TestCanReadAllRules(t *testing.T) {
testUser := &user.SignedInUser{}
t.Run("should check for provisioning permissions", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
expected := rand.Int()%2 == 1
rs.HasAccessFunc = func(ctx context.Context, requester identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return expected, nil
}
p := &provisioningRuleAccessControl{rs}
res, err := p.CanReadAllRules(context.Background(), testUser)
require.NoError(t, err)
require.Equal(t, expected, res)
require.Len(t, rs.Calls, 1)
require.Equal(t, "HasAccess", rs.Calls[0].MethodName)
require.Equal(t, accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningRead),
accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets),
).GoString(), rs.Calls[0].Arguments[2].(accesscontrol.Evaluator).GoString())
})
t.Run("should return error", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
expected := errors.New("test")
rs.HasAccessFunc = func(ctx context.Context, requester identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, expected
}
p := &provisioningRuleAccessControl{rs}
_, err := p.CanReadAllRules(context.Background(), testUser)
require.ErrorIs(t, err, expected)
})
}
func TestCanWriteAllRules(t *testing.T) {
testUser := &user.SignedInUser{}
t.Run("should check for provisioning permissions", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
expected := rand.Int()%2 == 1
rs.HasAccessFunc = func(ctx context.Context, requester identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return expected, nil
}
p := &provisioningRuleAccessControl{rs}
res, err := p.CanWriteAllRules(context.Background(), testUser)
require.NoError(t, err)
require.Equal(t, expected, res)
require.Len(t, rs.Calls, 1)
require.Equal(t, "HasAccess", rs.Calls[0].MethodName)
require.Equal(t, accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningWrite).GoString(), rs.Calls[0].Arguments[2].(accesscontrol.Evaluator).GoString())
})
t.Run("should return error", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
expected := errors.New("test")
rs.HasAccessFunc = func(ctx context.Context, requester identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, expected
}
p := &provisioningRuleAccessControl{rs}
_, err := p.CanWriteAllRules(context.Background(), testUser)
require.ErrorIs(t, err, expected)
})
}
func TestAuthorizeAccessToRuleGroup(t *testing.T) {
testUser := &user.SignedInUser{}
rules := models.GenerateAlertRules(1, models.AlertRuleGen())
t.Run("should return nil when user has provisioning permissions", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
provisioner := provisioningRuleAccessControl{
RuleAccessControlService: rs,
}
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return true, nil
}
err := provisioner.AuthorizeRuleGroupRead(context.Background(), testUser, rules)
require.NoError(t, err)
require.Len(t, rs.Calls, 1)
require.Equal(t, "HasAccess", rs.Calls[0].MethodName)
assert.Equal(t, accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningRead),
accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets),
).GoString(), rs.Calls[0].Arguments[2].(accesscontrol.Evaluator).GoString())
assert.Equal(t, testUser, rs.Calls[0].Arguments[1])
})
t.Run("should call upstream method if no provisioning permissions", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
provisioner := provisioningRuleAccessControl{
RuleAccessControlService: rs,
}
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, nil
}
rs.AuthorizeAccessToRuleGroupFunc = func(ctx context.Context, requester identity.Requester, group models.RulesGroup) error {
return nil
}
err := provisioner.AuthorizeRuleGroupRead(context.Background(), testUser, rules)
require.NoError(t, err)
require.Len(t, rs.Calls, 2)
require.Equal(t, "HasAccess", rs.Calls[0].MethodName)
require.Equal(t, "AuthorizeRuleGroupRead", rs.Calls[1].MethodName)
require.Equal(t, models.RulesGroup(rules), rs.Calls[1].Arguments[2])
})
t.Run("should propagate error", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
provisioner := provisioningRuleAccessControl{
RuleAccessControlService: rs,
}
expected := errors.New("test1")
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, expected
}
err := provisioner.AuthorizeRuleGroupRead(context.Background(), testUser, rules)
require.ErrorIs(t, err, expected)
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, nil
}
expected = errors.New("test2")
rs.AuthorizeAccessToRuleGroupFunc = func(ctx context.Context, requester identity.Requester, group models.RulesGroup) error {
return expected
}
err = provisioner.AuthorizeRuleGroupRead(context.Background(), testUser, rules)
require.ErrorIs(t, err, expected)
})
}
func TestAuthorizeRuleChanges(t *testing.T) {
testUser := &user.SignedInUser{}
change := &store.GroupDelta{}
t.Run("should return nil when user has provisioning permissions", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
provisioner := provisioningRuleAccessControl{
RuleAccessControlService: rs,
}
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return true, nil
}
err := provisioner.AuthorizeRuleGroupWrite(context.Background(), testUser, change)
require.NoError(t, err)
require.Len(t, rs.Calls, 1)
require.Equal(t, "HasAccess", rs.Calls[0].MethodName)
assert.Equal(t, accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningWrite).GoString(), rs.Calls[0].Arguments[2].(accesscontrol.Evaluator).GoString())
assert.Equal(t, testUser, rs.Calls[0].Arguments[1])
})
t.Run("should call upstream method if no provisioning permissions", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
provisioner := provisioningRuleAccessControl{
RuleAccessControlService: rs,
}
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, nil
}
rs.AuthorizeRuleChangesFunc = func(ctx context.Context, user identity.Requester, delta *store.GroupDelta) error {
return nil
}
err := provisioner.AuthorizeRuleGroupWrite(context.Background(), testUser, change)
require.NoError(t, err)
require.Len(t, rs.Calls, 2)
require.Equal(t, "HasAccess", rs.Calls[0].MethodName)
require.Equal(t, "AuthorizeRuleGroupWrite", rs.Calls[1].MethodName)
require.Equal(t, testUser, rs.Calls[1].Arguments[1])
require.Equal(t, change, rs.Calls[1].Arguments[2])
})
t.Run("should propagate error", func(t *testing.T) {
rs := &fakes.FakeRuleService{}
provisioner := provisioningRuleAccessControl{
RuleAccessControlService: rs,
}
expected := errors.New("test1")
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, expected
}
err := provisioner.AuthorizeRuleGroupWrite(context.Background(), testUser, change)
require.ErrorIs(t, err, expected)
rs.HasAccessFunc = func(ctx context.Context, user identity.Requester, evaluator accesscontrol.Evaluator) (bool, error) {
return false, nil
}
expected = errors.New("test2")
rs.AuthorizeRuleChangesFunc = func(ctx context.Context, requester identity.Requester, delta *store.GroupDelta) error {
return expected
}
err = provisioner.AuthorizeRuleGroupWrite(context.Background(), testUser, change)
require.ErrorIs(t, err, expected)
})
}

View File

@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/store"
@ -17,6 +18,15 @@ import (
"github.com/grafana/grafana/pkg/util"
)
type ruleAccessControlService interface {
AuthorizeRuleGroupRead(ctx context.Context, user identity.Requester, rules models.RulesGroup) error
AuthorizeRuleGroupWrite(ctx context.Context, user identity.Requester, change *store.GroupDelta) error
// CanReadAllRules returns true if the user has full access to read rules via provisioning API and bypass regular checks
CanReadAllRules(ctx context.Context, user identity.Requester) (bool, error)
// CanWriteAllRules returns true if the user has full access to write rules via provisioning API and bypass regular checks
CanWriteAllRules(ctx context.Context, user identity.Requester) (bool, error)
}
type NotificationSettingsValidatorProvider interface {
Validator(ctx context.Context, orgID int64) (notifier.NotificationSettingsValidator, error)
}
@ -33,6 +43,7 @@ type AlertRuleService struct {
xact TransactionManager
log log.Logger
nsValidatorProvider NotificationSettingsValidatorProvider
authz ruleAccessControlService
}
func NewAlertRuleService(ruleStore RuleStore,
@ -46,6 +57,7 @@ func NewAlertRuleService(ruleStore RuleStore,
rulesPerRuleGroupLimit int64,
log log.Logger,
ns NotificationSettingsValidatorProvider,
authz RuleAccessControlService,
) *AlertRuleService {
return &AlertRuleService{
defaultIntervalSeconds: defaultIntervalSeconds,
@ -59,6 +71,7 @@ func NewAlertRuleService(ruleStore RuleStore,
xact: xact,
log: log,
nsValidatorProvider: ns,
authz: newRuleAccessControlService(authz),
}
}
@ -78,23 +91,89 @@ func (service *AlertRuleService) GetAlertRules(ctx context.Context, user identit
return nil, nil, err
}
}
return rules, provenances, nil
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return nil, nil, err
}
if can {
return rules, provenances, nil
}
// If user does not have blanket privilege to read rules, remove all rules that are not allowed to the user.
groups := models.GroupByAlertRuleGroupKey(rules)
result := make([]*models.AlertRule, 0, len(rules))
for _, group := range groups {
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, group); err != nil {
if errors.Is(err, accesscontrol.ErrAuthorizationBase) {
// remove provenances for rules that will not be added to the output
for _, rule := range group {
delete(provenances, rule.ResourceID())
}
continue
}
return nil, nil, err
}
result = append(result, group...)
}
return result, provenances, nil
}
func (service *AlertRuleService) getAlertRuleAuthorized(ctx context.Context, user identity.Requester, ruleUID string) (models.AlertRule, error) {
// check if the user can read all rules. If it cannot, pull the entire group and verify access to the entire group.
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return models.AlertRule{}, err
}
// if user has blanket access to all rules, just read a single rule from database
if can {
query := &models.GetAlertRuleByUIDQuery{
OrgID: user.GetOrgID(),
UID: ruleUID,
}
rule, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
if err != nil {
return models.AlertRule{}, err
}
if rule == nil {
return models.AlertRule{}, models.ErrAlertRuleNotFound
}
return *rule, nil
}
// if user does not have privilege to access all rules, check that the user can read this rule by fetching entire group and
// checking that user has access to it.
q := &models.GetAlertRulesGroupByRuleUIDQuery{
UID: ruleUID,
OrgID: user.GetOrgID(),
}
group, err := service.ruleStore.GetAlertRulesGroupByRuleUID(ctx, q)
if err != nil {
return models.AlertRule{}, err
}
if len(group) == 0 {
return models.AlertRule{}, models.ErrAlertRuleNotFound
}
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, group); err != nil {
return models.AlertRule{}, err
}
for _, rule := range group {
if rule.UID == ruleUID {
return *rule, nil
}
}
return models.AlertRule{}, models.ErrAlertRuleNotFound
}
func (service *AlertRuleService) GetAlertRule(ctx context.Context, user identity.Requester, ruleUID string) (models.AlertRule, models.Provenance, error) {
query := &models.GetAlertRuleByUIDQuery{
OrgID: user.GetOrgID(),
UID: ruleUID,
}
rule, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
rule, err := service.getAlertRuleAuthorized(ctx, user, ruleUID)
if err != nil {
return models.AlertRule{}, models.ProvenanceNone, err
}
provenance, err := service.provenanceStore.GetProvenance(ctx, rule, user.GetOrgID())
provenance, err := service.provenanceStore.GetProvenance(ctx, &rule, user.GetOrgID())
if err != nil {
return models.AlertRule{}, models.ProvenanceNone, err
}
return *rule, provenance, nil
return rule, provenance, nil
}
type AlertRuleWithFolderTitle struct {
@ -104,11 +183,7 @@ type AlertRuleWithFolderTitle struct {
// GetAlertRuleWithFolderTitle returns a single alert rule with its folder title.
func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context, user identity.Requester, ruleUID string) (AlertRuleWithFolderTitle, error) {
query := &models.GetAlertRuleByUIDQuery{
OrgID: user.GetOrgID(),
UID: ruleUID,
}
rule, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
rule, err := service.getAlertRuleAuthorized(ctx, user, ruleUID)
if err != nil {
return AlertRuleWithFolderTitle{}, err
}
@ -124,7 +199,7 @@ func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context
}
return AlertRuleWithFolderTitle{
AlertRule: *rule,
AlertRule: rule,
FolderTitle: dash.Title,
}, nil
}
@ -138,13 +213,33 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident
} else if err := util.ValidateUID(rule.UID); err != nil {
return models.AlertRule{}, errors.Join(models.ErrAlertRuleFailedValidation, fmt.Errorf("cannot create rule with UID '%s': %w", rule.UID, err))
}
interval, err := service.ruleStore.GetRuleGroupInterval(ctx, rule.OrgID, rule.NamespaceUID, rule.RuleGroup)
// if the alert group does not exist we just use the default interval
if err != nil && errors.Is(err, models.ErrAlertRuleGroupNotFound) {
interval = service.defaultIntervalSeconds
} else if err != nil {
var interval = service.defaultIntervalSeconds
// check if user can bypass fine-grained rule authorization checks. If it cannot, verfiy that the user can add rules to the group
canWriteAllRules, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return models.AlertRule{}, err
}
if canWriteAllRules {
groupInterval, err := service.ruleStore.GetRuleGroupInterval(ctx, rule.OrgID, rule.NamespaceUID, rule.RuleGroup)
// if the alert group does not exist we just use the default interval
if err == nil {
interval = groupInterval
} else if !errors.Is(err, models.ErrAlertRuleGroupNotFound) {
return models.AlertRule{}, err
}
} else {
delta, err := store.CalculateRuleCreate(ctx, service.ruleStore, &rule)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to calculate delta: %w", err)
}
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return models.AlertRule{}, err
}
existingGroup := delta.AffectedGroups[rule.GetGroupKey()]
if len(existingGroup) > 0 {
interval = existingGroup[0].IntervalSeconds
}
}
rule.IntervalSeconds = interval
err = rule.SetDashboardAndPanelFromAnnotations()
if err != nil {
@ -209,11 +304,21 @@ func (service *AlertRuleService) GetRuleGroup(ctx context.Context, user identity
if len(ruleList) == 0 {
return models.AlertRuleGroup{}, models.ErrAlertRuleGroupNotFound.Errorf("")
}
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return models.AlertRuleGroup{}, err
}
if !can {
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, ruleList); err != nil {
return models.AlertRuleGroup{}, err
}
}
res := models.AlertRuleGroup{
Title: ruleList[0].RuleGroup,
FolderUID: ruleList[0].NamespaceUID,
Interval: ruleList[0].IntervalSeconds,
Rules: []models.AlertRule{},
Rules: make([]models.AlertRule, 0, len(ruleList)),
}
for _, r := range ruleList {
if r != nil {
@ -250,6 +355,39 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident
New: newRule,
})
}
// check if user has write access to all rules and can bypass the regular checks.
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
// If it cannot, check that the user is authorized to perform all the changes caused by this request
if !can {
groupKey := models.AlertRuleGroupKey{
OrgID: user.GetOrgID(),
NamespaceUID: namespaceUID,
RuleGroup: ruleGroup,
}
ruleDeltas := make([]store.RuleDelta, 0, len(ruleList))
for _, upd := range updateRules {
updNew := upd.New
ruleDeltas = append(ruleDeltas, store.RuleDelta{
Existing: upd.Existing,
New: &updNew,
})
}
delta := &store.GroupDelta{
GroupKey: groupKey,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: ruleList,
},
Update: ruleDeltas,
}
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
}
return service.ruleStore.UpdateAlertRules(ctx, updateRules)
})
}
@ -264,10 +402,22 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden
return err
}
if len(delta.New) == 0 && len(delta.Update) == 0 && len(delta.Delete) == 0 {
if delta.IsEmpty() {
return nil
}
// check if the current user has permissions to all rules and can bypass the regular authorization validation.
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
if !can {
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
}
newOrUpdatedNotificationSettings := delta.NewOrUpdatedNotificationSettings()
if len(newOrUpdatedNotificationSettings) > 0 {
validator, err := service.nsValidatorProvider.Validator(ctx, delta.GroupKey.OrgID)
@ -285,35 +435,27 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden
}
func (service *AlertRuleService) DeleteRuleGroup(ctx context.Context, user identity.Requester, namespaceUID, group string, provenance models.Provenance) error {
// List all rules in the group.
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
NamespaceUIDs: []string{namespaceUID},
RuleGroup: group,
}
ruleList, err := service.ruleStore.ListAlertRules(ctx, &q)
delta, err := store.CalculateRuleGroupDelete(ctx, service.ruleStore, models.AlertRuleGroupKey{
OrgID: user.GetOrgID(),
NamespaceUID: namespaceUID,
RuleGroup: group,
})
if err != nil {
return err
}
if len(ruleList) == 0 {
return models.ErrAlertRuleGroupNotFound.Errorf("")
}
// Check provenance for all rules in the group. Fail to delete if any deletions aren't allowed.
for _, rule := range ruleList {
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, rule, rule.OrgID)
if err != nil {
// check if the current user has permissions to all rules and can bypass the regular authorization validation.
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
if !can {
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
if storedProvenance != provenance && storedProvenance != models.ProvenanceNone {
return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
}
// Delete all rules.
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
return service.deleteRules(ctx, user.GetOrgID(), ruleList...)
})
return service.persistDelta(ctx, user, delta, provenance)
}
func (service *AlertRuleService) calcDelta(ctx context.Context, user identity.Requester, group models.AlertRuleGroup) (*store.GroupDelta, error) {
@ -346,7 +488,7 @@ func (service *AlertRuleService) calcDelta(ctx context.Context, user identity.Re
NamespaceUID: group.FolderUID,
RuleGroup: group.Title,
}
rules := make([]*models.AlertRuleWithOptionals, len(group.Rules))
rules := make([]*models.AlertRuleWithOptionals, 0, len(group.Rules))
group = *syncGroupRuleFields(&group, user.GetOrgID())
for i := range group.Rules {
if err := group.Rules[i].SetDashboardAndPanelFromAnnotations(); err != nil {
@ -374,7 +516,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
return err
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance)
return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
}
if err := service.deleteRules(ctx, user.GetOrgID(), delta.Delete...); err != nil {
@ -430,7 +572,41 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
// UpdateAlertRule updates an alert rule.
func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user identity.Requester, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) {
storedRule, storedProvenance, err := service.GetAlertRule(ctx, user, rule.UID)
var storedRule *models.AlertRule
// check if the user has full access to all rules and can bypass the regular authorization validations.
// If it cannot, calculate the changes to the group caused by this update and authorize them.
canWriteAllRules, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return models.AlertRule{}, err
}
if canWriteAllRules {
query := &models.GetAlertRuleByUIDQuery{
OrgID: rule.OrgID,
UID: rule.UID,
}
existing, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
if err != nil {
return models.AlertRule{}, err
}
storedRule = existing
} else {
delta, err := store.CalculateRuleUpdate(ctx, service.ruleStore, &models.AlertRuleWithOptionals{AlertRule: rule})
if err != nil {
return models.AlertRule{}, err
}
if err = service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return models.AlertRule{}, err
}
for _, d := range delta.Update {
if d.Existing.GetKey() == rule.GetKey() {
storedRule = d.Existing
}
}
if storedRule == nil { // this should not happen but we better catch it to avoid panic
return models.AlertRule{}, fmt.Errorf("cannot find rule in the delta")
}
}
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, storedRule, storedRule.OrgID)
if err != nil {
return models.AlertRule{}, err
}
@ -458,7 +634,7 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
err := service.ruleStore.UpdateAlertRules(ctx, []models.UpdateRule{
{
Existing: &storedRule,
Existing: storedRule,
New: rule,
},
})
@ -486,6 +662,24 @@ func (service *AlertRuleService) DeleteAlertRule(ctx context.Context, user ident
if storedProvenance != provenance && storedProvenance != models.ProvenanceNone {
return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
if !can {
delta, err := store.CalculateRuleDelete(ctx, service.ruleStore, rule.GetKey())
if err != nil {
return err
}
if err = service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
}
// The single delete is idempotent, and doesn't error when deleting a group that already doesn't exist.
// This is different from deleting groups. We delete the rules directly rather than persisting a delta here to keep the semantics the same.
// TODO: Either persist a delta here as a breaking change, or deprecate this endpoint in favor of the group endpoint.
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
return service.deleteRules(ctx, user.GetOrgID(), rule)
})
@ -536,18 +730,10 @@ func (service *AlertRuleService) deleteRules(ctx context.Context, orgID int64, t
// GetAlertRuleGroupWithFolderTitle returns the alert rule group with folder title.
func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Context, user identity.Requester, namespaceUID, group string) (models.AlertRuleGroupWithFolderTitle, error) {
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
NamespaceUIDs: []string{namespaceUID},
RuleGroup: group,
}
ruleList, err := service.ruleStore.ListAlertRules(ctx, &q)
ruleList, err := service.GetRuleGroup(ctx, user, namespaceUID, group)
if err != nil {
return models.AlertRuleGroupWithFolderTitle{}, err
}
if len(ruleList) == 0 {
return models.AlertRuleGroupWithFolderTitle{}, models.ErrAlertRuleGroupNotFound.Errorf("")
}
dq := dashboards.GetDashboardQuery{
OrgID: user.GetOrgID(),
@ -558,7 +744,7 @@ func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Co
return models.AlertRuleGroupWithFolderTitle{}, err
}
res := models.NewAlertRuleGroupWithFolderTitleFromRulesGroup(ruleList[0].GetGroupKey(), ruleList, dash.Title)
res := models.NewAlertRuleGroupWithFolderTitle(ruleList.Rules[0].GetGroupKey(), ruleList.Rules, dash.Title)
return res, nil
}
@ -576,16 +762,28 @@ func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Conte
if err != nil {
return nil, err
}
groups := models.GroupByAlertRuleGroupKey(ruleList)
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return nil, err
}
if !can {
// if user cannot read all rules, check read access to each group and remove groups that the user does not have access to
for key, group := range groups {
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, group); err != nil {
if errors.Is(err, accesscontrol.ErrAuthorizationBase) {
delete(groups, key)
continue
}
return nil, err
}
}
}
groups := make(map[models.AlertRuleGroupKey][]models.AlertRule)
namespaces := make(map[string][]*models.AlertRuleGroupKey)
for _, r := range ruleList {
groupKey := r.GetGroupKey()
group := groups[groupKey]
group = append(group, *r)
groups[groupKey] = group
namespaces[r.NamespaceUID] = append(namespaces[r.NamespaceUID], &groupKey)
for groupKey := range groups {
namespaces[groupKey.NamespaceUID] = append(namespaces[groupKey.NamespaceUID], util.Pointer(groupKey))
}
if len(namespaces) == 0 {
@ -615,7 +813,7 @@ func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Conte
if !ok {
return nil, fmt.Errorf("cannot find title for folder with uid '%s'", groupKey.NamespaceUID)
}
result = append(result, models.NewAlertRuleGroupWithFolderTitle(groupKey, rules, title))
result = append(result, models.NewAlertRuleGroupWithFolderTitleFromRulesGroup(groupKey, rules, title))
}
// Return results in a stable manner.

File diff suppressed because it is too large Load Diff

View File

@ -2,13 +2,16 @@ package provisioning
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/assert"
mock "github.com/stretchr/testify/mock"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/store"
)
const defaultAlertmanagerConfigJSON = `
@ -147,3 +150,61 @@ type NotificationSettingsValidatorProviderFake struct {
func (n *NotificationSettingsValidatorProviderFake) Validator(ctx context.Context, orgID int64) (notifier.NotificationSettingsValidator, error) {
return notifier.NoValidation{}, nil
}
type call struct {
Method string
Args []interface{}
}
type fakeRuleAccessControlService struct {
mu sync.Mutex
Calls []call
AuthorizeAccessToRuleGroupFunc func(ctx context.Context, user identity.Requester, rules models.RulesGroup) error
AuthorizeRuleChangesFunc func(ctx context.Context, user identity.Requester, change *store.GroupDelta) error
CanReadAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error)
CanWriteAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error)
}
func (s *fakeRuleAccessControlService) RecordCall(method string, args ...interface{}) {
s.mu.Lock()
defer s.mu.Unlock()
call := call{
Method: method,
Args: args,
}
s.Calls = append(s.Calls, call)
}
func (s *fakeRuleAccessControlService) AuthorizeRuleGroupRead(ctx context.Context, user identity.Requester, rules models.RulesGroup) error {
s.RecordCall("AuthorizeRuleGroupRead", ctx, user, rules)
if s.AuthorizeAccessToRuleGroupFunc != nil {
return s.AuthorizeAccessToRuleGroupFunc(ctx, user, rules)
}
return nil
}
func (s *fakeRuleAccessControlService) AuthorizeRuleGroupWrite(ctx context.Context, user identity.Requester, change *store.GroupDelta) error {
s.RecordCall("AuthorizeRuleGroupWrite", ctx, user, change)
if s.AuthorizeRuleChangesFunc != nil {
return s.AuthorizeRuleChangesFunc(ctx, user, change)
}
return nil
}
func (s *fakeRuleAccessControlService) CanReadAllRules(ctx context.Context, user identity.Requester) (bool, error) {
s.RecordCall("CanReadAllRules", ctx, user)
if s.CanReadAllRulesFunc != nil {
return s.CanReadAllRulesFunc(ctx, user)
}
return false, nil
}
func (s *fakeRuleAccessControlService) CanWriteAllRules(ctx context.Context, user identity.Requester) (bool, error) {
s.RecordCall("CanWriteAllRules", ctx, user)
if s.CanWriteAllRulesFunc != nil {
return s.CanWriteAllRulesFunc(ctx, user)
}
return false, nil
}

View File

@ -60,7 +60,6 @@ type RuleReader interface {
// CalculateChanges calculates the difference between rules in the group in the database and the submitted rules. If a submitted rule has UID it tries to find it in the database (in other groups).
// returns a list of rules that need to be added, updated and deleted. Deleted considered rules in the database that belong to the group but do not exist in the list of submitted rules.
func CalculateChanges(ctx context.Context, ruleReader RuleReader, groupKey models.AlertRuleGroupKey, submittedRules []*models.AlertRuleWithOptionals) (*GroupDelta, error) {
affectedGroups := make(map[models.AlertRuleGroupKey]models.RulesGroup)
q := &models.ListAlertRulesQuery{
OrgID: groupKey.OrgID,
NamespaceUIDs: []string{groupKey.NamespaceUID},
@ -70,6 +69,13 @@ func CalculateChanges(ctx context.Context, ruleReader RuleReader, groupKey model
if err != nil {
return nil, fmt.Errorf("failed to query database for rules in the group %s: %w", groupKey, err)
}
return calculateChanges(ctx, ruleReader, groupKey, existingGroupRules, submittedRules)
}
func calculateChanges(ctx context.Context, ruleReader RuleReader, groupKey models.AlertRuleGroupKey, existingGroupRules []*models.AlertRule, submittedRules []*models.AlertRuleWithOptionals) (*GroupDelta, error) {
affectedGroups := make(map[models.AlertRuleGroupKey]models.RulesGroup)
if len(existingGroupRules) > 0 {
affectedGroups[groupKey] = existingGroupRules
}
@ -191,3 +197,114 @@ func UpdateCalculatedRuleFields(ch *GroupDelta) *GroupDelta {
Delete: ch.Delete,
}
}
// CalculateRuleUpdate calculates GroupDelta for rule update operation
func CalculateRuleUpdate(ctx context.Context, ruleReader RuleReader, rule *models.AlertRuleWithOptionals) (*GroupDelta, error) {
q := &models.ListAlertRulesQuery{
OrgID: rule.OrgID,
NamespaceUIDs: []string{rule.NamespaceUID},
RuleGroup: rule.RuleGroup,
}
existingGroupRules, err := ruleReader.ListAlertRules(ctx, q)
if err != nil {
return nil, err
}
newGroup := make([]*models.AlertRuleWithOptionals, 0, len(existingGroupRules)+1)
added := false
for _, alertRule := range existingGroupRules {
if alertRule.GetKey() == rule.GetKey() {
newGroup = append(newGroup, rule)
added = true
}
newGroup = append(newGroup, &models.AlertRuleWithOptionals{AlertRule: *alertRule})
}
if !added {
newGroup = append(newGroup, rule)
}
return calculateChanges(ctx, ruleReader, rule.GetGroupKey(), existingGroupRules, newGroup)
}
// CalculateRuleGroupDelete calculates GroupDelta that reflects an operation of removing entire group
func CalculateRuleGroupDelete(ctx context.Context, ruleReader RuleReader, groupKey models.AlertRuleGroupKey) (*GroupDelta, error) {
// List all rules in the group.
q := models.ListAlertRulesQuery{
OrgID: groupKey.OrgID,
NamespaceUIDs: []string{groupKey.NamespaceUID},
RuleGroup: groupKey.RuleGroup,
}
ruleList, err := ruleReader.ListAlertRules(ctx, &q)
if err != nil {
return nil, err
}
if len(ruleList) == 0 {
return nil, models.ErrAlertRuleGroupNotFound.Errorf("")
}
delta := &GroupDelta{
GroupKey: groupKey,
Delete: ruleList,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: ruleList,
},
}
return delta, nil
}
// CalculateRuleDelete calculates GroupDelta that reflects an operation of removing a rule from the group.
func CalculateRuleDelete(ctx context.Context, ruleReader RuleReader, ruleKey models.AlertRuleKey) (*GroupDelta, error) {
q := &models.GetAlertRulesGroupByRuleUIDQuery{
UID: ruleKey.UID,
OrgID: ruleKey.OrgID,
}
group, err := ruleReader.GetAlertRulesGroupByRuleUID(ctx, q)
if err != nil {
return nil, err
}
var toDelete *models.AlertRule
for _, rule := range group {
if rule.GetKey() == ruleKey {
toDelete = rule
break
}
}
if toDelete == nil { // should not happen if rule exists.
return nil, models.ErrAlertRuleNotFound
}
groupKey := group[0].GetGroupKey()
delta := &GroupDelta{
GroupKey: groupKey,
Delete: []*models.AlertRule{toDelete},
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: group,
},
}
return delta, nil
}
// CalculateRuleCreate calculates GroupDelta that reflects an operation of adding a new rule to the group.
func CalculateRuleCreate(ctx context.Context, ruleReader RuleReader, rule *models.AlertRule) (*GroupDelta, error) {
q := &models.ListAlertRulesQuery{
OrgID: rule.OrgID,
NamespaceUIDs: []string{rule.NamespaceUID},
RuleGroup: rule.RuleGroup,
}
group, err := ruleReader.ListAlertRules(ctx, q)
if err != nil {
return nil, err
}
delta := &GroupDelta{
GroupKey: rule.GetGroupKey(),
AffectedGroups: make(map[models.AlertRuleGroupKey]models.RulesGroup),
New: []*models.AlertRule{rule},
Update: nil,
Delete: nil,
}
if len(group) > 0 {
delta.AffectedGroups[rule.GetGroupKey()] = group
}
return delta, nil
}

View File

@ -416,6 +416,174 @@ func TestCalculateAutomaticChanges(t *testing.T) {
})
}
func TestCalculateRuleGroupDelete(t *testing.T) {
fakeStore := fakes.NewRuleStore(t)
groupKey := models.GenerateGroupKey(1)
otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(groupKey.OrgID), models.WithNamespaceUIDNotIn(groupKey.NamespaceUID)))
fakeStore.Rules[groupKey.OrgID] = otherRules
t.Run("NotFound when group does not exist", func(t *testing.T) {
delta, err := CalculateRuleGroupDelete(context.Background(), fakeStore, groupKey)
require.ErrorIs(t, err, models.ErrAlertRuleGroupNotFound, "expected ErrAlertRuleGroupNotFound but got %s", err)
require.Nil(t, delta)
})
t.Run("set AffectedGroups when a rule refers to an existing group", func(t *testing.T) {
groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(groupKey)))
fakeStore.Rules[groupKey.OrgID] = append(fakeStore.Rules[groupKey.OrgID], groupRules...)
delta, err := CalculateRuleGroupDelete(context.Background(), fakeStore, groupKey)
require.NoError(t, err)
assert.Equal(t, groupKey, delta.GroupKey)
assert.EqualValues(t, groupRules, delta.Delete)
assert.Empty(t, delta.Update)
assert.Empty(t, delta.New)
assert.Len(t, delta.AffectedGroups, 1)
assert.Equal(t, models.RulesGroup(groupRules), delta.AffectedGroups[delta.GroupKey])
})
}
func TestCalculateRuleDelete(t *testing.T) {
fakeStore := fakes.NewRuleStore(t)
rule := models.AlertRuleGen()()
otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(rule.OrgID), models.WithNamespaceUIDNotIn(rule.NamespaceUID)))
fakeStore.Rules[rule.OrgID] = otherRules
t.Run("nil when a rule does not exist", func(t *testing.T) {
delta, err := CalculateRuleDelete(context.Background(), fakeStore, rule.GetKey())
require.ErrorIs(t, err, models.ErrAlertRuleNotFound)
require.Nil(t, delta)
})
t.Run("set AffectedGroups when a rule refers to an existing group", func(t *testing.T) {
groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(rule.GetGroupKey())))
groupRules = append(groupRules, rule)
fakeStore.Rules[rule.OrgID] = append(fakeStore.Rules[rule.OrgID], groupRules...)
delta, err := CalculateRuleDelete(context.Background(), fakeStore, rule.GetKey())
require.NoError(t, err)
assert.Equal(t, rule.GetGroupKey(), delta.GroupKey)
assert.Len(t, delta.Delete, 1)
assert.Equal(t, rule, delta.Delete[0])
assert.Empty(t, delta.Update)
assert.Empty(t, delta.New)
assert.Len(t, delta.AffectedGroups, 1)
assert.Equal(t, models.RulesGroup(groupRules), delta.AffectedGroups[delta.GroupKey])
})
}
func TestCalculateRuleUpdate(t *testing.T) {
fakeStore := fakes.NewRuleStore(t)
rule := models.AlertRuleGen()()
otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(rule.OrgID), models.WithNamespaceUIDNotIn(rule.NamespaceUID)))
groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(rule.GetGroupKey())))
groupRules = append(groupRules, rule)
fakeStore.Rules[rule.OrgID] = append(otherRules, groupRules...)
t.Run("when a rule is not changed", func(t *testing.T) {
cp := models.CopyRule(rule)
delta, err := CalculateRuleUpdate(context.Background(), fakeStore, &models.AlertRuleWithOptionals{
AlertRule: *cp,
HasPause: false,
})
require.NoError(t, err)
require.True(t, delta.IsEmpty())
})
t.Run("when a rule is updated", func(t *testing.T) {
cp := models.CopyRule(rule)
cp.For = cp.For + 1*time.Minute // cause any diff
delta, err := CalculateRuleUpdate(context.Background(), fakeStore, &models.AlertRuleWithOptionals{
AlertRule: *cp,
HasPause: false,
})
require.NoError(t, err)
assert.Equal(t, rule.GetGroupKey(), delta.GroupKey)
assert.Empty(t, delta.New)
assert.Empty(t, delta.Delete)
assert.Len(t, delta.Update, 1)
assert.Equal(t, cp, delta.Update[0].New)
assert.Equal(t, rule, delta.Update[0].Existing)
require.Contains(t, delta.AffectedGroups, delta.GroupKey)
assert.Equal(t, models.RulesGroup(groupRules), delta.AffectedGroups[delta.GroupKey])
})
t.Run("when a rule is moved between groups", func(t *testing.T) {
sourceGroupKey := rule.GetGroupKey()
targetGroupKey := models.GenerateGroupKey(rule.OrgID)
targetGroup := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(targetGroupKey)))
fakeStore.Rules[rule.OrgID] = append(fakeStore.Rules[rule.OrgID], targetGroup...)
cp := models.CopyRule(rule)
cp.NamespaceUID = targetGroupKey.NamespaceUID
cp.RuleGroup = targetGroupKey.RuleGroup
delta, err := CalculateRuleUpdate(context.Background(), fakeStore, &models.AlertRuleWithOptionals{
AlertRule: *cp,
HasPause: false,
})
require.NoError(t, err)
assert.Equal(t, targetGroupKey, delta.GroupKey)
assert.Empty(t, delta.New)
assert.Empty(t, delta.Delete)
assert.Len(t, delta.Update, 1)
assert.Equal(t, cp, delta.Update[0].New)
assert.Equal(t, rule, delta.Update[0].Existing)
require.Contains(t, delta.AffectedGroups, sourceGroupKey)
assert.Equal(t, models.RulesGroup(groupRules), delta.AffectedGroups[sourceGroupKey])
require.Contains(t, delta.AffectedGroups, targetGroupKey)
assert.Equal(t, models.RulesGroup(targetGroup), delta.AffectedGroups[targetGroupKey])
})
}
func TestCalculateRuleCreate(t *testing.T) {
t.Run("when a rule refers to a new group", func(t *testing.T) {
fakeStore := fakes.NewRuleStore(t)
rule := models.AlertRuleGen()()
delta, err := CalculateRuleCreate(context.Background(), fakeStore, rule)
require.NoError(t, err)
assert.Equal(t, rule.GetGroupKey(), delta.GroupKey)
assert.Empty(t, delta.AffectedGroups)
assert.Empty(t, delta.Delete)
assert.Empty(t, delta.Update)
assert.Len(t, delta.New, 1)
assert.Equal(t, rule, delta.New[0])
})
t.Run("when a rule refers to an existing group", func(t *testing.T) {
fakeStore := fakes.NewRuleStore(t)
rule := models.AlertRuleGen()()
groupRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithGroupKey(rule.GetGroupKey())))
otherRules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(rule.OrgID), models.WithNamespaceUIDNotIn(rule.NamespaceUID)))
fakeStore.Rules[rule.OrgID] = append(groupRules, otherRules...)
delta, err := CalculateRuleCreate(context.Background(), fakeStore, rule)
require.NoError(t, err)
assert.Equal(t, rule.GetGroupKey(), delta.GroupKey)
assert.Len(t, delta.AffectedGroups, 1)
assert.Equal(t, models.RulesGroup(groupRules), delta.AffectedGroups[delta.GroupKey])
assert.Empty(t, delta.Delete)
assert.Empty(t, delta.Update)
assert.Len(t, delta.New, 1)
assert.Equal(t, rule, delta.New[0])
})
}
// simulateSubmitted resets some fields of the structure that are not populated by API model to model conversion
func simulateSubmitted(rule *models.AlertRule) {
rule.ID = 0

View File

@ -2,7 +2,6 @@ package fakes
import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
@ -283,6 +282,12 @@ func (f *RuleStore) InsertAlertRules(_ context.Context, q []models.AlertRule) ([
defer f.mtx.Unlock()
f.RecordedOps = append(f.RecordedOps, q)
ids := make([]models.AlertRuleKeyWithId, 0, len(q))
for _, rule := range q {
ids = append(ids, models.AlertRuleKeyWithId{
AlertRuleKey: rule.GetKey(),
ID: rand.Int63(),
})
}
if err := f.Hook(q); err != nil {
return ids, err
}
@ -296,12 +301,16 @@ func (f *RuleStore) InTransaction(ctx context.Context, fn func(c context.Context
func (f *RuleStore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) {
f.mtx.Lock()
defer f.mtx.Unlock()
f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{
Name: "GetRuleGroupInterval",
Params: []any{orgID, namespaceUID, ruleGroup},
})
for _, rule := range f.Rules[orgID] {
if rule.RuleGroup == ruleGroup && rule.NamespaceUID == namespaceUID {
return rule.IntervalSeconds, nil
}
}
return 0, errors.New("rule group not found")
return 0, models.ErrAlertRuleGroupNotFound.Errorf("")
}
func (f *RuleStore) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string, interval int64) error {

View File

@ -7,12 +7,13 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
alert_models "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
)
@ -132,7 +133,16 @@ func (prov *defaultAlertRuleProvisioner) getOrCreateFolderUID(
return cmdResult.UID, nil
}
// UserID is 0 to use org quota
var provisionerUser = func(orgID int64) identity.Requester {
return &user.SignedInUser{UserID: 0, Login: "alert_provisioner", OrgID: orgID}
// this user has 0 ID and therefore, organization wide quota will be applied
return accesscontrol.BackgroundUser(
"alert_provisioner",
orgID,
org.RoleAdmin,
[]accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
{Action: accesscontrol.ActionAlertingProvisioningReadSecrets, Scope: dashboards.ScopeFoldersAll},
{Action: accesscontrol.ActionAlertingProvisioningWrite, Scope: dashboards.ScopeFoldersAll},
},
)
}

View File

@ -15,6 +15,7 @@ import (
datasourceservice "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/encryption"
"github.com/grafana/grafana/pkg/services/folder"
alertingauthz "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
@ -255,7 +256,10 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error
int64(ps.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()),
int64(ps.Cfg.UnifiedAlerting.BaseInterval.Seconds()),
ps.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit,
ps.log, notifier.NewCachedNotificationSettingsValidationService(&st))
ps.log,
notifier.NewCachedNotificationSettingsValidationService(&st),
alertingauthz.NewRuleService(ps.ac),
)
receiverSvc := notifier.NewReceiverService(ps.ac, &st, st, ps.secretService, ps.SQLStore, ps.log)
contactPointService := provisioning.NewContactPointService(&st, ps.secretService,
st, ps.SQLStore, receiverSvc, ps.log, &st)

View File

@ -10198,7 +10198,7 @@
"tags": [
"provisioning"
],
"summary": "Update the interval of a rule group.",
"summary": "Create or update alert rule group.",
"operationId": "RoutePutAlertRuleGroup",
"parameters": [
{

View File

@ -23342,7 +23342,7 @@
"description": "ValidationError"
}
},
"summary": "Update the interval of a rule group.",
"summary": "Create or update alert rule group.",
"tags": [
"provisioning"
]