diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index d7a08fe8b4a..c122a19c1be 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -67,7 +67,12 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *models.ReqContext) response.Respons return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqOrgAdminOrEditor, evaluator) } - var canDelete, cannotDelete []string + provenances, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.SignedInUser.OrgId, (&ngmodels.AlertRule{}).ResourceType()) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to fetch provenances of alert rules") + } + + var deletableRules []string err = srv.xactManager.InTransaction(c.Req.Context(), func(ctx context.Context) error { q := ngmodels.ListAlertRulesQuery{ OrgID: c.SignedInUser.OrgId, @@ -83,24 +88,52 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *models.ReqContext) response.Respons return nil } - canDelete = make([]string, 0, len(q.Result)) - for _, rule := range q.Result { - if authorizeDatasourceAccessForRule(rule, hasAccess) { - canDelete = append(canDelete, rule.UID) - continue + var canDelete []*ngmodels.AlertRule + var cannotDelete []string + + // partition will partation the given rules in two, one partition + // being the rules that fulfill the predicate the other partation being + // the ruleIDs not fulfilling it. + partition := func(alerts []*ngmodels.AlertRule, predicate func(rule *ngmodels.AlertRule) bool) ([]*ngmodels.AlertRule, []string) { + positive, negative := make([]*ngmodels.AlertRule, 0, len(alerts)), make([]string, 0, len(alerts)) + for _, rule := range alerts { + if predicate(rule) { + positive = append(positive, rule) + continue + } + negative = append(negative, rule.UID) } - cannotDelete = append(cannotDelete, rule.UID) + return positive, negative } + canDelete, cannotDelete = partition(q.Result, func(rule *ngmodels.AlertRule) bool { + return authorizeDatasourceAccessForRule(rule, hasAccess) + }) if len(canDelete) == 0 { return fmt.Errorf("%w to delete rules because user is not authorized to access data sources used by the rules", ErrAuthorization) } - if len(cannotDelete) > 0 { logger.Info("user cannot delete one or many alert rules because it does not have access to data sources. Those rules will be skipped", "expected", len(q.Result), "authorized", len(canDelete), "unauthorized", cannotDelete) } - return srv.store.DeleteAlertRulesByUID(ctx, c.SignedInUser.OrgId, canDelete...) + canDelete, cannotDelete = partition(canDelete, func(rule *ngmodels.AlertRule) bool { + provenance, exists := provenances[rule.UID] + return (exists && provenance == ngmodels.ProvenanceNone) || !exists + }) + + if len(canDelete) == 0 { + return fmt.Errorf("all rules have been provisioned and cannot be deleted through this api") + } + + if len(cannotDelete) > 0 { + logger.Info("user cannot delete one or many alert rules because it does have a provenance set. Those rules will be skipped", "expected", len(q.Result), "provenance_none", len(canDelete), "provenance_set", cannotDelete) + } + + for _, rule := range canDelete { + deletableRules = append(deletableRules, rule.UID) + } + + return srv.store.DeleteAlertRulesByUID(ctx, c.SignedInUser.OrgId, deletableRules...) }) if err != nil { @@ -112,7 +145,7 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *models.ReqContext) response.Respons logger.Debug("rules have been deleted from the store. updating scheduler") - for _, uid := range canDelete { + for _, uid := range deletableRules { srv.scheduleService.DeleteAlertRule(ngmodels.AlertRuleKey{ OrgID: c.SignedInUser.OrgId, UID: uid, @@ -335,8 +368,9 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf // updateAlertRulesInGroup calculates changes (rules to add,update,delete), verifies that the user is authorized to do the calculated changes and updates database. // All operations are performed in a single transaction +//nolint: gocyclo func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *models.Folder, groupName string, rules []*ngmodels.AlertRule) response.Response { - var authorizedChanges *changes + var finalChanges *changes hasAccess := accesscontrol.HasAccess(srv.ac, c) err := srv.xactManager.InTransaction(c.Req.Context(), func(tranCtx context.Context) error { logger := srv.log.New("namespace_uid", namespace.Uid, "group", groupName, "org_id", c.OrgId, "user_id", c.UserId) @@ -347,12 +381,12 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *mod } if groupChanges.isEmpty() { - authorizedChanges = groupChanges + finalChanges = groupChanges logger.Info("no changes detected in the request. Do nothing") return nil } - authorizedChanges, err = authorizeRuleChanges(namespace, groupChanges, func(evaluator accesscontrol.Evaluator) bool { + authorizedChanges, err := authorizeRuleChanges(namespace, groupChanges, func(evaluator accesscontrol.Evaluator) bool { return hasAccess(accesscontrol.ReqOrgAdminOrEditor, evaluator) }) if err != nil { @@ -368,19 +402,58 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *mod logger.Info("user is not authorized to delete one or many rules in the group. those rules will be skipped", "expected", len(groupChanges.Delete), "authorized", len(authorizedChanges.Delete)) } - logger.Debug("updating database with the authorized changes", "add", len(authorizedChanges.New), "update", len(authorizedChanges.New), "delete", len(authorizedChanges.Delete)) + provenances, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.OrgId, (&ngmodels.AlertRule{}).ResourceType()) + if err != nil { + return err + } - if len(authorizedChanges.Update) > 0 || len(authorizedChanges.New) > 0 { - updates := make([]store.UpdateRule, 0, len(authorizedChanges.Update)) - inserts := make([]ngmodels.AlertRule, 0, len(authorizedChanges.New)) - for _, update := range authorizedChanges.Update { + // New rules don't need to be checked for provenance, just copy the whole slice. + finalChanges = &changes{} + finalChanges.New = authorizedChanges.New + for _, rule := range authorizedChanges.Update { + if provenance, exists := provenances[rule.Existing.UID]; (exists && provenance == ngmodels.ProvenanceNone) || !exists { + finalChanges.Update = append(finalChanges.Update, rule) + } + } + for _, rule := range authorizedChanges.Delete { + if provenance, exists := provenances[rule.UID]; (exists && provenance == ngmodels.ProvenanceNone) || !exists { + finalChanges.Delete = append(finalChanges.Delete, rule) + } + } + + if finalChanges.isEmpty() { + logger.Info("no changes detected that have 'none' provenance in the request. Do nothing", + "provenance_invalid_add", len(authorizedChanges.New), + "provenance_invalid_update", len(authorizedChanges.Update), + "provenance_invalid_delete", len(authorizedChanges.Delete)) + return nil + } + + if len(authorizedChanges.Delete) > len(finalChanges.Delete) { + logger.Info("provenance is not 'none' for one or many rules in the group that should be deleted. those rules will be skipped", + "expected", len(authorizedChanges.Delete), + "allowed", len(authorizedChanges.Delete)) + } + + if len(authorizedChanges.Update) > len(finalChanges.Update) { + logger.Info("provenance is not 'none' for one or many rules in the group that should be updated. those rules will be skipped", + "expected", len(authorizedChanges.Update), + "allowed", len(authorizedChanges.Update)) + } + + logger.Debug("updating database with the authorized changes", "add", len(finalChanges.New), "update", len(finalChanges.New), "delete", len(finalChanges.Delete)) + + if len(finalChanges.Update) > 0 || len(finalChanges.New) > 0 { + updates := make([]store.UpdateRule, 0, len(finalChanges.Update)) + inserts := make([]ngmodels.AlertRule, 0, len(finalChanges.New)) + for _, update := range finalChanges.Update { logger.Debug("updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String()) updates = append(updates, store.UpdateRule{ Existing: update.Existing, New: *update.New, }) } - for _, rule := range authorizedChanges.New { + for _, rule := range finalChanges.New { inserts = append(inserts, *rule) } err = srv.store.InsertAlertRules(tranCtx, inserts) @@ -393,9 +466,9 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *mod } } - if len(authorizedChanges.Delete) > 0 { - UIDs := make([]string, 0, len(authorizedChanges.Delete)) - for _, rule := range authorizedChanges.Delete { + if len(finalChanges.Delete) > 0 { + UIDs := make([]string, 0, len(finalChanges.Delete)) + for _, rule := range finalChanges.Delete { UIDs = append(UIDs, rule.UID) } @@ -404,7 +477,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *mod } } - if len(authorizedChanges.New) > 0 { + if len(finalChanges.New) > 0 { limitReached, err := srv.QuotaService.CheckQuotaReached(tranCtx, "alert_rule", "a.ScopeParameters{ OrgId: c.OrgId, UserId: c.UserId, @@ -432,21 +505,21 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, namespace *mod return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") } - for _, rule := range authorizedChanges.Update { + for _, rule := range finalChanges.Update { srv.scheduleService.UpdateAlertRule(ngmodels.AlertRuleKey{ OrgID: c.SignedInUser.OrgId, UID: rule.Existing.UID, }) } - for _, rule := range authorizedChanges.Delete { + for _, rule := range finalChanges.Delete { srv.scheduleService.DeleteAlertRule(ngmodels.AlertRuleKey{ OrgID: c.SignedInUser.OrgId, UID: rule.UID, }) } - if authorizedChanges.isEmpty() { + if finalChanges.isEmpty() { return response.JSON(http.StatusAccepted, util.DynMap{"message": "no changes detected in the rule group"}) } diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index f0bfbb70c0a..1f64133b49b 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -376,6 +376,32 @@ func TestRouteDeleteAlertRules(t *testing.T) { require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, rulesInFolderInGroup, ruleStore, scheduler) }) + t.Run("editor shouldn't be able to delete provisioned rules", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + ruleStore.PutRule(context.Background(), rulesInFolder...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything) + + ac := acMock.New().WithDisabled() + + svc := createService(ac, ruleStore, scheduler) + + err := svc.provenanceStore.SetProvenance(context.Background(), rulesInFolder[0], orgID, models.ProvenanceAPI) + require.NoError(t, err) + + request := createRequestContext(orgID, models2.ROLE_EDITOR, map[string]string{ + ":Namespace": folder.Title, + }) + response := svc.RouteDeleteAlertRules(request) + require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) + assertRulesDeleted(t, rulesInFolder[1:], ruleStore, scheduler) + }) }) t.Run("when fine-grained access is enabled", func(t *testing.T) { t.Run("and user does not have access to any of data sources used by alert rules", func(t *testing.T) { @@ -421,6 +447,32 @@ func TestRouteDeleteAlertRules(t *testing.T) { require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) assertRulesDeleted(t, rulesInFolder, ruleStore, scheduler) }) + t.Run("shouldn't be able to delete provisioned rules", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + ruleStore.PutRule(context.Background(), rulesInFolder...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything) + + ac := acMock.New().WithPermissions(createPermissionsForRules(rulesInFolder)) + svc := createService(ac, ruleStore, scheduler) + + err := svc.provenanceStore.SetProvenance(context.Background(), rulesInFolder[0], orgID, models.ProvenanceAPI) + require.NoError(t, err) + + request := createRequestContext(orgID, "None", map[string]string{ + ":Namespace": folder.Title, + }) + + response := svc.RouteDeleteAlertRules(request) + require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) + assertRulesDeleted(t, rulesInFolder[1:], ruleStore, scheduler) + }) }) t.Run("and user has access to data sources of some of alert rules", func(t *testing.T) { t.Run("should delete only those that are accessible in folder", func(t *testing.T) {