From 92d995d6582b2312e564b44664dfb95a5b756f2c Mon Sep 17 00:00:00 2001 From: "Grot (@grafanabot)" <43478413+grafanabot@users.noreply.github.com> Date: Sat, 4 Jun 2022 09:13:02 -0400 Subject: [PATCH] Alerting: Add provenance guard to config api (#50147) (#50209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Alerting: add provenance guard to config api * add tests * only guard if config valid * adapt error message * simplify logic * rename arguments * make logic more straight forward * rename opt to options * remove useless maps (cherry picked from commit 4cc8c6f7459ed892e0c11e0aa2c561df2d79f34e) Co-authored-by: Jean-Philippe Quéméner --- pkg/services/ngalert/api/api_alertmanager.go | 10 +- .../ngalert/api/api_alertmanager_guards.go | 146 +++++ .../api/api_alertmanager_guards_test.go | 589 ++++++++++++++++++ 3 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 pkg/services/ngalert/api/api_alertmanager_guards.go create mode 100644 pkg/services/ngalert/api/api_alertmanager_guards_test.go diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 26317fbbd34..1f9cdce877e 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -217,7 +217,15 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *models.ReqContext) response.Respo } func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body apimodels.PostableUserConfig) response.Response { - err := srv.mam.ApplyAlertmanagerConfiguration(c.Req.Context(), c.OrgId, body) + currentConfig, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.OrgId) + // If a config is present and valid we proceed with the guard, otherwise we + // just bypass the guard which is okay as we are anyway in an invalid state. + if err == nil { + if err := srv.provenanceGuard(currentConfig, body); err != nil { + return ErrResp(http.StatusBadRequest, err, "") + } + } + err = srv.mam.ApplyAlertmanagerConfiguration(c.Req.Context(), c.OrgId, body) if err == nil { return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration created"}) } diff --git a/pkg/services/ngalert/api/api_alertmanager_guards.go b/pkg/services/ngalert/api/api_alertmanager_guards.go new file mode 100644 index 00000000000..d619b22b8d5 --- /dev/null +++ b/pkg/services/ngalert/api/api_alertmanager_guards.go @@ -0,0 +1,146 @@ +package api + +import ( + "fmt" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util/cmputil" + amConfig "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" +) + +func (srv AlertmanagerSrv) provenanceGuard(currentConfig apimodels.GettableUserConfig, newConfig apimodels.PostableUserConfig) error { + if err := checkRoutes(currentConfig, newConfig); err != nil { + return err + } + if err := checkTemplates(currentConfig, newConfig); err != nil { + return err + } + if err := checkContactPoints(currentConfig.AlertmanagerConfig.Receivers, newConfig.AlertmanagerConfig.Receivers); err != nil { + return err + } + if err := checkMuteTimes(currentConfig, newConfig); err != nil { + return err + } + return nil +} + +func checkRoutes(currentConfig apimodels.GettableUserConfig, newConfig apimodels.PostableUserConfig) error { + reporter := cmputil.DiffReporter{} + options := []cmp.Option{cmp.Reporter(&reporter), cmpopts.EquateEmpty(), cmpopts.IgnoreUnexported(labels.Matcher{})} + routesEqual := cmp.Equal(currentConfig.AlertmanagerConfig.Route, newConfig.AlertmanagerConfig.Route, options...) + if !routesEqual && currentConfig.AlertmanagerConfig.Route.Provenance != ngmodels.ProvenanceNone { + return fmt.Errorf("policies were provisioned and cannot be changed through the UI") + } + return nil +} + +func checkTemplates(currentConfig apimodels.GettableUserConfig, newConfig apimodels.PostableUserConfig) error { + for name, template := range currentConfig.TemplateFiles { + provenance := ngmodels.ProvenanceNone + if prov, present := currentConfig.TemplateFileProvenances[name]; present { + provenance = prov + } + if provenance == ngmodels.ProvenanceNone { + continue // we are only interested in non none + } + found := false + for newName, newTemplate := range newConfig.TemplateFiles { + if name != newName { + continue + } + found = true + if template != newTemplate { + return fmt.Errorf("cannot save provisioned template '%s'", name) + } + break // we found the template and we can proceed + } + if !found { + return fmt.Errorf("cannot delete provisioned template '%s'", name) + } + } + return nil +} + +func checkContactPoints(currReceivers []*apimodels.GettableApiReceiver, newReceivers []*apimodels.PostableApiReceiver) error { + newCPs := make(map[string]*apimodels.PostableGrafanaReceiver) + for _, postedReceiver := range newReceivers { + for _, postedContactPoint := range postedReceiver.GrafanaManagedReceivers { + newCPs[postedContactPoint.UID] = postedContactPoint + } + } + for _, existingReceiver := range currReceivers { + for _, contactPoint := range existingReceiver.GrafanaManagedReceivers { + if contactPoint.Provenance == ngmodels.ProvenanceNone { + continue // we are only interested in non none + } + postedContactPoint, present := newCPs[contactPoint.UID] + if !present { + return fmt.Errorf("cannot delete provisioned contact point '%s'", contactPoint.Name) + } + editErr := fmt.Errorf("cannot save provisioned contact point '%s'", contactPoint.Name) + if contactPoint.DisableResolveMessage != postedContactPoint.DisableResolveMessage { + return editErr + } + if contactPoint.Name != postedContactPoint.Name { + return editErr + } + if contactPoint.Type != postedContactPoint.Type { + return editErr + } + for key := range contactPoint.SecureFields { + if value, present := postedContactPoint.SecureSettings[key]; present && value != "" { + return editErr + } + } + existingSettings, err := contactPoint.Settings.Map() + if err != nil { + return err + } + newSettings, err := postedContactPoint.Settings.Map() + if err != nil { + return err + } + for key, val := range existingSettings { + if newVal, present := newSettings[key]; present { + if val != newVal { + return editErr + } + } else { + return editErr + } + } + } + } + return nil +} + +func checkMuteTimes(currentConfig apimodels.GettableUserConfig, newConfig apimodels.PostableUserConfig) error { + newMTs := make(map[string]amConfig.MuteTimeInterval) + for _, newMuteTime := range newConfig.AlertmanagerConfig.MuteTimeIntervals { + newMTs[newMuteTime.Name] = newMuteTime + } + for _, muteTime := range currentConfig.AlertmanagerConfig.MuteTimeIntervals { + provenance := ngmodels.ProvenanceNone + if prov, present := currentConfig.AlertmanagerConfig.MuteTimeProvenances[muteTime.Name]; present { + provenance = prov + } + if provenance == ngmodels.ProvenanceNone { + continue // we are only interested in non none + } + postedMT, present := newMTs[muteTime.Name] + if !present { + return fmt.Errorf("cannot delete provisioned mute time '%s'", muteTime.Name) + } + reporter := cmputil.DiffReporter{} + options := []cmp.Option{cmp.Reporter(&reporter), cmpopts.EquateEmpty()} + timesEqual := cmp.Equal(muteTime.TimeIntervals, postedMT.TimeIntervals, options...) + if !timesEqual { + return fmt.Errorf("cannot save provisioned mute time '%s'", muteTime.Name) + } + } + return nil +} diff --git a/pkg/services/ngalert/api/api_alertmanager_guards_test.go b/pkg/services/ngalert/api/api_alertmanager_guards_test.go new file mode 100644 index 00000000000..a87733d1c3a --- /dev/null +++ b/pkg/services/ngalert/api/api_alertmanager_guards_test.go @@ -0,0 +1,589 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + amConfig "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/prometheus/alertmanager/timeinterval" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" +) + +func TestCheckRoute(t *testing.T) { + tests := []struct { + name string + shouldErr bool + currentConfig definitions.GettableUserConfig + newConfig definitions.PostableUserConfig + }{ + { + name: "equal configs should not error", + shouldErr: false, + currentConfig: gettableRoute(t, models.ProvenanceAPI), + newConfig: postableRoute(t, models.ProvenanceAPI), + }, + { + name: "editing a non provisioned object should not fail", + shouldErr: false, + currentConfig: gettableRoute(t, models.ProvenanceNone), + newConfig: func() definitions.PostableUserConfig { + cfg := postableRoute(t, models.ProvenanceNone) + cfg.AlertmanagerConfig.Route.Matchers[0].Value = "123" + return cfg + }(), + }, + { + name: "editing a provisioned object should fail", + shouldErr: true, + currentConfig: gettableRoute(t, models.ProvenanceAPI), + newConfig: func() definitions.PostableUserConfig { + cfg := postableRoute(t, models.ProvenanceAPI) + cfg.AlertmanagerConfig.Route.Matchers[0].Value = "123" + return cfg + }(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := checkRoutes(test.currentConfig, test.newConfig) + if test.shouldErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func gettableRoute(t *testing.T, provenance models.Provenance) definitions.GettableUserConfig { + t.Helper() + return definitions.GettableUserConfig{ + AlertmanagerConfig: definitions.GettableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Provenance: provenance, + Continue: true, + GroupBy: []model.LabelName{ + "...", + }, + Matchers: amConfig.Matchers{ + { + Name: "a", + Type: labels.MatchEqual, + Value: "b", + }, + }, + Routes: []*definitions.Route{ + { + Matchers: amConfig.Matchers{ + { + Name: "x", + Type: labels.MatchNotEqual, + Value: "y", + }, + }, + }, + }, + }, + }, + }, + } +} + +func postableRoute(t *testing.T, provenace models.Provenance) definitions.PostableUserConfig { + t.Helper() + return definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + Route: &definitions.Route{ + Provenance: provenace, + Continue: true, + GroupBy: []model.LabelName{ + "...", + }, + Matchers: amConfig.Matchers{ + { + Name: "a", + Type: labels.MatchEqual, + Value: "b", + }, + }, + Routes: []*definitions.Route{ + { + Matchers: amConfig.Matchers{ + { + Name: "x", + Type: labels.MatchNotEqual, + Value: "y", + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestCheckTemplates(t *testing.T) { + tests := []struct { + name string + shouldErr bool + currentConfig definitions.GettableUserConfig + newConfig definitions.PostableUserConfig + }{ + { + name: "equal configs should not error", + shouldErr: false, + currentConfig: gettableTemplates(t, "test-1", models.ProvenanceAPI), + newConfig: postableTemplate(t, "test-1"), + }, + { + name: "removing a non provisioned object should not fail", + shouldErr: false, + currentConfig: gettableTemplates(t, "test-1", models.ProvenanceNone), + newConfig: definitions.PostableUserConfig{}, + }, + { + name: "removing a provisioned object should fail", + shouldErr: true, + currentConfig: gettableTemplates(t, "test-1", models.ProvenanceAPI), + newConfig: definitions.PostableUserConfig{}, + }, + { + name: "adding a non provisioned object should not fail", + shouldErr: false, + currentConfig: gettableTemplates(t, "test-1", models.ProvenanceAPI), + newConfig: postableTemplate(t, "test-1", "test-2"), + }, + { + name: "editing a non provisioned object should not fail", + shouldErr: false, + currentConfig: gettableTemplates(t, "test-1", models.ProvenanceNone), + newConfig: func() definitions.PostableUserConfig { + cfg := postableTemplate(t, "test-1") + cfg.TemplateFiles["test-1"] = "some updated value" + return cfg + }(), + }, + { + name: "editing a provisioned object should fail", + shouldErr: true, + currentConfig: gettableTemplates(t, "test-1", models.ProvenanceAPI), + newConfig: func() definitions.PostableUserConfig { + cfg := postableTemplate(t, "test-1") + cfg.TemplateFiles["test-1"] = "some updated value" + return cfg + }(), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := checkTemplates(test.currentConfig, test.newConfig) + if test.shouldErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func gettableTemplates(t *testing.T, name string, provenance models.Provenance) definitions.GettableUserConfig { + t.Helper() + return definitions.GettableUserConfig{ + TemplateFiles: map[string]string{ + name: "some-template", + }, + TemplateFileProvenances: map[string]models.Provenance{ + name: provenance, + }, + } +} + +func postableTemplate(t *testing.T, names ...string) definitions.PostableUserConfig { + t.Helper() + files := map[string]string{} + for _, name := range names { + files[name] = "some-template" + } + return definitions.PostableUserConfig{ + TemplateFiles: files, + } +} + +func TestCheckContactPoints(t *testing.T) { + tests := []struct { + name string + shouldErr bool + currentConfig []*definitions.GettableApiReceiver + newConfig []*definitions.PostableApiReceiver + }{ + { + name: "equal configs should not error", + shouldErr: false, + currentConfig: []*definitions.GettableApiReceiver{ + defaultGettableReceiver(t, "test-1", models.ProvenanceAPI), + }, + newConfig: []*definitions.PostableApiReceiver{ + defaultPostableReceiver(t, "test-1"), + }, + }, + { + name: "removing a non provisioned object should not fail", + shouldErr: false, + currentConfig: []*definitions.GettableApiReceiver{ + defaultGettableReceiver(t, "test-1", models.ProvenanceNone), + }, + newConfig: []*definitions.PostableApiReceiver{}, + }, + { + name: "removing a provisioned object should fail", + shouldErr: true, + currentConfig: []*definitions.GettableApiReceiver{ + defaultGettableReceiver(t, "test-1", models.ProvenanceAPI), + }, + newConfig: []*definitions.PostableApiReceiver{}, + }, + { + name: "adding a non provisioned object should not fail", + shouldErr: false, + currentConfig: []*definitions.GettableApiReceiver{ + defaultGettableReceiver(t, "test-1", models.ProvenanceAPI), + }, + newConfig: []*definitions.PostableApiReceiver{ + defaultPostableReceiver(t, "test-1"), + defaultPostableReceiver(t, "test-2"), + }, + }, + { + name: "editing a non provisioned object should not fail", + shouldErr: false, + currentConfig: []*definitions.GettableApiReceiver{ + defaultGettableReceiver(t, "test-1", models.ProvenanceNone), + }, + newConfig: []*definitions.PostableApiReceiver{ + func() *definitions.PostableApiReceiver { + receiver := defaultPostableReceiver(t, "test-1") + receiver.GrafanaManagedReceivers[0].SecureSettings = map[string]string{ + "url": "newUrl", + } + return receiver + }(), + }, + }, + { + name: "editing a provisioned object should fail", + shouldErr: true, + currentConfig: []*definitions.GettableApiReceiver{ + defaultGettableReceiver(t, "test-1", models.ProvenanceAPI), + }, + newConfig: []*definitions.PostableApiReceiver{ + func() *definitions.PostableApiReceiver { + receiver := defaultPostableReceiver(t, "test-1") + receiver.GrafanaManagedReceivers[0].SecureSettings = map[string]string{ + "url": "newUrl", + } + return receiver + }(), + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := checkContactPoints(test.currentConfig, test.newConfig) + if test.shouldErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func defaultGettableReceiver(t *testing.T, uid string, provenance models.Provenance) *definitions.GettableApiReceiver { + t.Helper() + return &definitions.GettableApiReceiver{ + GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{ + { + UID: "123", + Name: "yeah", + Type: "slack", + DisableResolveMessage: true, + Provenance: provenance, + SecureFields: map[string]bool{ + "url": true, + }, + Settings: simplejson.NewFromAny(map[string]interface{}{ + "hello": "world", + }), + }, + }, + }, + } +} + +func defaultPostableReceiver(t *testing.T, uid string) *definitions.PostableApiReceiver { + t.Helper() + return &definitions.PostableApiReceiver{ + PostableGrafanaReceivers: definitions.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*definitions.PostableGrafanaReceiver{ + { + UID: "123", + Name: "yeah", + Type: "slack", + DisableResolveMessage: true, + Settings: simplejson.NewFromAny(map[string]interface{}{ + "hello": "world", + }), + }, + }, + }, + } +} + +func TestCheckMuteTimes(t *testing.T) { + tests := []struct { + name string + shouldErr bool + currentConfig definitions.GettableUserConfig + newConfig definitions.PostableUserConfig + }{ + { + name: "equal configs should not error", + shouldErr: false, + currentConfig: gettableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + { + Name: "test-2", + TimeIntervals: defaultInterval(t), + }, + }, + map[string]models.Provenance{ + "test-1": models.ProvenanceNone, + }), + newConfig: postableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + { + Name: "test-2", + TimeIntervals: defaultInterval(t), + }, + }), + }, + { + name: "removing a non provisioned object should not fail", + shouldErr: false, + currentConfig: gettableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + }, + map[string]models.Provenance{ + "test-1": models.ProvenanceNone, + }), + newConfig: postableMuteIntervals(t, []amConfig.MuteTimeInterval{}), + }, + { + name: "removing a provisioned object should fail", + shouldErr: true, + currentConfig: gettableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + { + Name: "test-2", + TimeIntervals: defaultInterval(t), + }, + }, + map[string]models.Provenance{ + "test-1": models.ProvenanceAPI, + }), + newConfig: postableMuteIntervals(t, []amConfig.MuteTimeInterval{ + { + Name: "test-2", + TimeIntervals: defaultInterval(t), + }, + }), + }, + { + name: "adding a non provisioned object should not fail", + shouldErr: false, + currentConfig: gettableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + }, + map[string]models.Provenance{ + "test-1": models.ProvenanceNone, + }), + newConfig: postableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + { + Name: "test-2", + TimeIntervals: defaultInterval(t), + }, + }), + }, + { + name: "editing a non provisioned object should not fail", + shouldErr: false, + currentConfig: gettableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + }, + map[string]models.Provenance{ + "test-1": models.ProvenanceNone, + }), + newConfig: postableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: func() []timeinterval.TimeInterval { + intervals := defaultInterval(t) + intervals[0].Times = []timeinterval.TimeRange{ + { + StartMinute: 10, + EndMinute: 50, + }, + } + return intervals + }(), + }, + }), + }, + { + name: "editing a provisioned object should fail", + shouldErr: true, + currentConfig: gettableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: defaultInterval(t), + }, + }, + map[string]models.Provenance{ + "test-1": models.ProvenanceAPI, + }), + newConfig: postableMuteIntervals(t, + []amConfig.MuteTimeInterval{ + { + Name: "test-1", + TimeIntervals: func() []timeinterval.TimeInterval { + intervals := defaultInterval(t) + intervals[0].Times = []timeinterval.TimeRange{ + { + StartMinute: 10, + EndMinute: 50, + }, + } + return intervals + }(), + }, + }), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := checkMuteTimes(test.currentConfig, test.newConfig) + if test.shouldErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func gettableMuteIntervals(t *testing.T, muteTimeIntervals []amConfig.MuteTimeInterval, provenances map[string]models.Provenance) definitions.GettableUserConfig { + return definitions.GettableUserConfig{ + AlertmanagerConfig: definitions.GettableApiAlertingConfig{ + MuteTimeProvenances: provenances, + Config: definitions.Config{ + MuteTimeIntervals: muteTimeIntervals, + }, + }, + } +} + +func postableMuteIntervals(t *testing.T, muteTimeIntervals []amConfig.MuteTimeInterval) definitions.PostableUserConfig { + t.Helper() + return definitions.PostableUserConfig{ + AlertmanagerConfig: definitions.PostableApiAlertingConfig{ + Config: definitions.Config{ + MuteTimeIntervals: muteTimeIntervals, + }, + }, + } +} + +func defaultInterval(t *testing.T) []timeinterval.TimeInterval { + t.Helper() + return []timeinterval.TimeInterval{ + { + Years: []timeinterval.YearRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 2002, + End: 2008, + }, + }, + }, + Times: []timeinterval.TimeRange{ + { + StartMinute: 10, + EndMinute: 40, + }, + }, + Weekdays: []timeinterval.WeekdayRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, + End: 5, + }, + }, + }, + DaysOfMonth: []timeinterval.DayOfMonthRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, + End: 20, + }, + }, + }, + Months: []timeinterval.MonthRange{ + { + InclusiveRange: timeinterval.InclusiveRange{ + Begin: 1, + End: 6, + }, + }, + }, + }, + } +}