diff --git a/pkg/services/ngalert/accesscontrol/silences_test.go b/pkg/services/ngalert/accesscontrol/silences_test.go index 570c51f4773..7544229fa45 100644 --- a/pkg/services/ngalert/accesscontrol/silences_test.go +++ b/pkg/services/ngalert/accesscontrol/silences_test.go @@ -5,15 +5,18 @@ import ( "math/rand" "testing" + amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + alertingModels "github.com/grafana/alerting/models" ac "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/ngalert/models" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils" + "github.com/grafana/grafana/pkg/util" ) var orgID = rand.Int63() @@ -474,7 +477,16 @@ func TestAuthorizeUpdateSilence(t *testing.T) { } func testSilence(id string, ruleUID *string) *models.Silence { - return &models.Silence{ID: &id, RuleUID: ruleUID} + s := &models.Silence{ID: &id} + if ruleUID != nil { + s.Matchers = amv2.Matchers{{ + IsEqual: util.Pointer(true), + IsRegex: util.Pointer(false), + Name: util.Pointer(alertingModels.RuleUIDLabel), + Value: ruleUID, + }} + } + return s } type fakeRuleUIDToNamespaceStore struct { diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index fcbe4e498ea..c3195ee4c35 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -92,7 +92,13 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { api.RegisterAlertmanagerApiEndpoints(NewForkingAM( api.DatasourceCache, NewLotexAM(proxy, logger), - &AlertmanagerSrv{crypto: api.MultiOrgAlertmanager.Crypto, log: logger, ac: api.AccessControl, mam: api.MultiOrgAlertmanager}, + &AlertmanagerSrv{ + crypto: api.MultiOrgAlertmanager.Crypto, + log: logger, + ac: api.AccessControl, + mam: api.MultiOrgAlertmanager, + silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(api.AccessControl, api.RuleStore), api.TransactionManager, logger, api.MultiOrgAlertmanager), + }, ), m) // Register endpoints for proxying to Prometheus-compatible backends. api.RegisterPrometheusApiEndpoints(NewForkingProm( diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index a508c02e3a2..41205be0897 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -28,10 +28,11 @@ const ( ) type AlertmanagerSrv struct { - log log.Logger - ac accesscontrol.AccessControl - mam *notifier.MultiOrgAlertmanager - crypto notifier.Crypto + log log.Logger + ac accesscontrol.AccessControl + mam *notifier.MultiOrgAlertmanager + crypto notifier.Crypto + silenceSvc SilenceService } type UnknownReceiverError struct { diff --git a/pkg/services/ngalert/api/api_alertmanager_silences.go b/pkg/services/ngalert/api/api_alertmanager_silences.go index 6226e73a528..8c4f92c5095 100644 --- a/pkg/services/ngalert/api/api_alertmanager_silences.go +++ b/pkg/services/ngalert/api/api_alertmanager_silences.go @@ -1,112 +1,71 @@ package api import ( - "errors" - "fmt" + "context" "net/http" "github.com/go-openapi/strfmt" - alertingNotify "github.com/grafana/alerting/notify" "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/auth/identity" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - authz "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/util" ) +// SilenceService is the service for managing and authenticating silences access in Grafana AM. +type SilenceService interface { + GetSilence(ctx context.Context, user identity.Requester, silenceID string) (*models.Silence, error) + ListSilences(ctx context.Context, user identity.Requester, filter []string) ([]*models.Silence, error) + CreateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error) + UpdateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error) + DeleteSilence(ctx context.Context, user identity.Requester, silenceID string) error +} + +// RouteGetSilence is the single silence GET endpoint for Grafana AM. func (srv AlertmanagerSrv) RouteGetSilence(c *contextmodel.ReqContext, silenceID string) response.Response { - am, errResp := srv.AlertmanagerFor(c.SignedInUser.GetOrgID()) - if errResp != nil { - return errResp - } - - gettableSilence, err := am.GetSilence(c.Req.Context(), silenceID) + silence, err := srv.silenceSvc.GetSilence(c.Req.Context(), c.SignedInUser, silenceID) if err != nil { - if errors.Is(err, alertingNotify.ErrSilenceNotFound) { - return ErrResp(http.StatusNotFound, err, "") - } - // any other error here should be an unexpected failure and thus an internal error - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to get silence", err) } - return response.JSON(http.StatusOK, gettableSilence) + return response.JSON(http.StatusOK, SilenceToGettableSilence(*silence)) } +// RouteGetSilences is the silence list GET endpoint for Grafana AM. func (srv AlertmanagerSrv) RouteGetSilences(c *contextmodel.ReqContext) response.Response { - am, errResp := srv.AlertmanagerFor(c.SignedInUser.GetOrgID()) - if errResp != nil { - return errResp - } - - gettableSilences, err := am.ListSilences(c.Req.Context(), c.QueryStrings("filter")) + silences, err := srv.silenceSvc.ListSilences(c.Req.Context(), c.SignedInUser, c.QueryStrings("filter")) if err != nil { - if errors.Is(err, alertingNotify.ErrListSilencesBadPayload) { - return ErrResp(http.StatusBadRequest, err, "") - } - // any other error here should be an unexpected failure and thus an internal error - return ErrResp(http.StatusInternalServerError, err, "") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to list silence", err) } - return response.JSON(http.StatusOK, gettableSilences) + return response.JSON(http.StatusOK, SilencesToGettableSilences(silences)) } +// RouteCreateSilence is the silence POST (create + update) endpoint for Grafana AM. func (srv AlertmanagerSrv) RouteCreateSilence(c *contextmodel.ReqContext, postableSilence apimodels.PostableSilence) response.Response { err := postableSilence.Validate(strfmt.Default) if err != nil { srv.log.Error("Silence failed validation", "error", err) return ErrResp(http.StatusBadRequest, err, "silence failed validation") } - - action := accesscontrol.ActionAlertingInstanceUpdate + action := srv.silenceSvc.UpdateSilence if postableSilence.ID == "" { - action = accesscontrol.ActionAlertingInstanceCreate + action = srv.silenceSvc.CreateSilence } - evaluator := accesscontrol.EvalPermission(action) - if !accesscontrol.HasAccess(srv.ac, c)(evaluator) { - errAction := "update" - if postableSilence.ID == "" { - errAction = "create" - } - return response.Err(authz.NewAuthorizationErrorWithPermissions(fmt.Sprintf("%s silences", errAction), evaluator)) - } - - silenceID, err := srv.mam.CreateSilence(c.Req.Context(), c.SignedInUser.GetOrgID(), &postableSilence) + silenceID, err := action(c.Req.Context(), c.SignedInUser, PostableSilenceToSilence(postableSilence)) if err != nil { - if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) { - return ErrResp(http.StatusNotFound, err, "") - } - if errors.Is(err, notifier.ErrAlertmanagerNotReady) { - return ErrResp(http.StatusConflict, err, "") - } - - if errors.Is(err, alertingNotify.ErrSilenceNotFound) { - return ErrResp(http.StatusNotFound, err, "") - } - - if errors.Is(err, alertingNotify.ErrCreateSilenceBadPayload) { - return ErrResp(http.StatusBadRequest, err, "") - } - - return ErrResp(http.StatusInternalServerError, err, "failed to create silence") + return response.ErrOrFallback(http.StatusInternalServerError, "failed to create/update silence", err) } + return response.JSON(http.StatusAccepted, apimodels.PostSilencesOKBody{ SilenceID: silenceID, }) } +// RouteDeleteSilence is the silence DELETE endpoint for Grafana AM. func (srv AlertmanagerSrv) RouteDeleteSilence(c *contextmodel.ReqContext, silenceID string) response.Response { - if err := srv.mam.DeleteSilence(c.Req.Context(), c.SignedInUser.GetOrgID(), silenceID); err != nil { - if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) { - return ErrResp(http.StatusNotFound, err, "") - } - if errors.Is(err, notifier.ErrAlertmanagerNotReady) { - return ErrResp(http.StatusConflict, err, "") - } - if errors.Is(err, alertingNotify.ErrSilenceNotFound) { - return ErrResp(http.StatusNotFound, err, "") - } - return ErrResp(http.StatusInternalServerError, err, "") + if err := srv.silenceSvc.DeleteSilence(c.Req.Context(), c.SignedInUser, silenceID); err != nil { + return response.ErrOrFallback(http.StatusInternalServerError, "failed to delete silence", err) } return response.JSON(http.StatusOK, util.DynMap{"message": "silence deleted"}) } diff --git a/pkg/services/ngalert/api/api_alertmanager_silences_test.go b/pkg/services/ngalert/api/api_alertmanager_silences_test.go index a2279dd0603..0b379db4b4a 100644 --- a/pkg/services/ngalert/api/api_alertmanager_silences_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_silences_test.go @@ -2,7 +2,6 @@ package api import ( "context" - "math/rand" "net/http" "testing" "time" @@ -13,10 +12,10 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - 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/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -67,7 +66,10 @@ func TestSilenceCreate(t *testing.T) { OrgRole: org.RoleEditor, OrgID: 1, Permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, + 1: { + accesscontrol.ActionAlertingInstanceRead: {}, + accesscontrol.ActionAlertingInstanceCreate: {}, + }, }, }, } @@ -86,13 +88,13 @@ func TestSilenceCreate(t *testing.T) { func TestRouteCreateSilence(t *testing.T) { tesCases := []struct { name string - silence func() apimodels.PostableSilence + silence func() ngmodels.Silence permissions map[int64]map[string][]string expectedStatus int }{ { name: "new silence, role-based access control is enabled, not authorized", - silence: silenceGen(withEmptyID), + silence: ngmodels.SilenceGen(ngmodels.SilenceMuts.WithEmptyId()), permissions: map[int64]map[string][]string{ 1: {}, }, @@ -100,15 +102,18 @@ func TestRouteCreateSilence(t *testing.T) { }, { name: "new silence, role-based access control is enabled, authorized", - silence: silenceGen(withEmptyID), + silence: ngmodels.SilenceGen(ngmodels.SilenceMuts.WithEmptyId()), permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, + 1: { + accesscontrol.ActionAlertingInstanceRead: {}, + accesscontrol.ActionAlertingInstanceCreate: {}, + }, }, expectedStatus: http.StatusAccepted, }, { name: "update silence, role-based access control is enabled, not authorized", - silence: silenceGen(), + silence: ngmodels.SilenceGen(), permissions: map[int64]map[string][]string{ 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, }, @@ -116,9 +121,12 @@ func TestRouteCreateSilence(t *testing.T) { }, { name: "update silence, role-based access control is enabled, authorized", - silence: silenceGen(), + silence: ngmodels.SilenceGen(), permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingInstanceUpdate: {}}, + 1: { + accesscontrol.ActionAlertingInstanceRead: {}, + accesscontrol.ActionAlertingInstanceUpdate: {}, + }, }, expectedStatus: http.StatusAccepted, }, @@ -138,57 +146,19 @@ func TestRouteCreateSilence(t *testing.T) { }, } - silence := tesCase.silence() + silence := notifier.SilenceToPostableSilence(tesCase.silence()) if silence.ID != "" { alertmanagerFor, err := sut.mam.AlertmanagerFor(1) require.NoError(t, err) silence.ID = "" - newID, err := alertmanagerFor.CreateSilence(context.Background(), &silence) + newID, err := alertmanagerFor.CreateSilence(context.Background(), silence) require.NoError(t, err) silence.ID = newID } - response := sut.RouteCreateSilence(&rc, silence) + response := sut.RouteCreateSilence(&rc, *silence) require.Equal(t, tesCase.expectedStatus, response.Status()) }) } } - -func silenceGen(mutatorFuncs ...func(*apimodels.PostableSilence)) func() apimodels.PostableSilence { - return func() apimodels.PostableSilence { - testString := util.GenerateShortUID() - isEqual := rand.Int()%2 == 0 - isRegex := rand.Int()%2 == 0 - value := util.GenerateShortUID() - if isRegex { - value = ".*" + util.GenerateShortUID() - } - - matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &isEqual, IsRegex: &isRegex, Value: &value}} - comment := util.GenerateShortUID() - starts := strfmt.DateTime(timeNow().Add(-time.Duration(rand.Int63n(9)+1) * time.Second)) - ends := strfmt.DateTime(timeNow().Add(time.Duration(rand.Int63n(9)+1) * time.Second)) - createdBy := "User-" + util.GenerateShortUID() - s := apimodels.PostableSilence{ - ID: util.GenerateShortUID(), - Silence: amv2.Silence{ - Comment: &comment, - CreatedBy: &createdBy, - EndsAt: &ends, - Matchers: matchers, - StartsAt: &starts, - }, - } - - for _, mutator := range mutatorFuncs { - mutator(&s) - } - - return s - } -} - -func withEmptyID(silence *apimodels.PostableSilence) { - silence.ID = "" -} diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index a9b04aa3934..f3cd85313cc 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" @@ -630,11 +631,14 @@ func createSut(t *testing.T) AlertmanagerSrv { } mam := createMultiOrgAlertmanager(t, configs) log := log.NewNopLogger() + ac := acimpl.ProvideAccessControl(setting.NewCfg()) + ruleStore := ngfakes.NewRuleStore(t) return AlertmanagerSrv{ - mam: mam, - crypto: mam.Crypto, - ac: acimpl.ProvideAccessControl(setting.NewCfg()), - log: log, + mam: mam, + crypto: mam.Crypto, + ac: ac, + log: log, + silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(ac, ruleStore), ruleStore, log, mam), } } diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index c6eef344de6..81c5252f501 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -124,16 +124,42 @@ func (api *API) authorize(method, path string) web.Handler { // Alert Instances and Silences - // Silences. Grafana Paths - case http.MethodDelete + "/api/alertmanager/grafana/api/v2/silence/{SilenceId}": - eval = ac.EvalPermission(ac.ActionAlertingInstanceUpdate) // delete endpoint actually expires silence + // Silences for Grafana paths. + // These permissions are required but not sufficient, further authorization is done in the request handler. + case http.MethodDelete + "/api/alertmanager/grafana/api/v2/silence/{SilenceId}": // Delete endpoint is used for silence expiration. + eval = ac.EvalAll( + ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingInstanceRead), + ac.EvalPermission(ac.ActionAlertingSilencesRead), + ), + ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingInstanceUpdate), + ac.EvalPermission(ac.ActionAlertingSilencesWrite), + ), + ) case http.MethodGet + "/api/alertmanager/grafana/api/v2/silence/{SilenceId}": - eval = ac.EvalPermission(ac.ActionAlertingInstanceRead) + eval = ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingInstanceRead), + ac.EvalPermission(ac.ActionAlertingSilencesRead), + ) case http.MethodGet + "/api/alertmanager/grafana/api/v2/silences": - eval = ac.EvalPermission(ac.ActionAlertingInstanceRead) + eval = ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingInstanceRead), + ac.EvalPermission(ac.ActionAlertingSilencesRead), + ) case http.MethodPost + "/api/alertmanager/grafana/api/v2/silences": - // additional authorization is done in the request handler - eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceCreate), ac.EvalPermission(ac.ActionAlertingInstanceUpdate)) + eval = ac.EvalAll( + ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingInstanceRead), + ac.EvalPermission(ac.ActionAlertingSilencesRead), + ), + ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingInstanceCreate), + ac.EvalPermission(ac.ActionAlertingInstanceUpdate), + ac.EvalPermission(ac.ActionAlertingSilencesCreate), + ac.EvalPermission(ac.ActionAlertingSilencesWrite), + ), + ) // Alert Instances. Grafana Paths case http.MethodGet + "/api/alertmanager/grafana/api/v2/alerts/groups": diff --git a/pkg/services/ngalert/api/compat_silences.go b/pkg/services/ngalert/api/compat_silences.go new file mode 100644 index 00000000000..77b5cff9dda --- /dev/null +++ b/pkg/services/ngalert/api/compat_silences.go @@ -0,0 +1,30 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// Silence-specific compat functions to convert between API and model types. + +func SilenceToGettableSilence(s models.Silence) definitions.GettableSilence { + return definitions.GettableSilence(s) +} + +func SilencesToGettableSilences(silences []*models.Silence) definitions.GettableSilences { + res := make(definitions.GettableSilences, 0, len(silences)) + for _, sil := range silences { + apiSil := SilenceToGettableSilence(*sil) + res = append(res, &apiSil) + } + return res +} + +func PostableSilenceToSilence(s definitions.PostableSilence) models.Silence { + return models.Silence{ + ID: &s.ID, + Status: nil, + UpdatedAt: nil, + Silence: s.Silence, + } +} diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index ebefb6e33ab..698d78e3703 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -26,4 +27,6 @@ type RuleStore interface { // IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error) + + accesscontrol.RuleUIDToNamespaceStore } diff --git a/pkg/services/ngalert/models/silence.go b/pkg/services/ngalert/models/silence.go index dbf9a0946de..fa7c391cb4e 100644 --- a/pkg/services/ngalert/models/silence.go +++ b/pkg/services/ngalert/models/silence.go @@ -1,11 +1,37 @@ package models -// Silence is the model-layer representation of an alertmanager silence. -type Silence struct { // TODO implement using matchers - ID *string - RuleUID *string +import ( + amv2 "github.com/prometheus/alertmanager/api/v2/models" + + alertingModels "github.com/grafana/alerting/models" + "github.com/grafana/alerting/notify" +) + +// Silence is the model-layer representation of an alertmanager silence. Currently just a wrapper around the +// alerting notify.Silence. +type Silence notify.GettableSilence + +// GetRuleUID returns the rule UID of the silence if the silence is associated with a rule, otherwise nil. +// Currently, this works by looking for a matcher with the RuleUIDLabel name and returning its value. +func (s Silence) GetRuleUID() *string { + return getRuleUIDLabelValue(s.Silence) } -func (s *Silence) GetRuleUID() *string { - return s.RuleUID +// getRuleUIDLabelValue returns the value of the RuleUIDLabel matcher in the given silence, if it exists. +func getRuleUIDLabelValue(silence notify.Silence) *string { + for _, m := range silence.Matchers { + if m != nil && isRuleUIDMatcher(*m) { + return m.Value + } + } + return nil +} + +func isRuleUIDMatcher(m amv2.Matcher) bool { + return isEqualMatcher(m) && m.Name != nil && *m.Name == alertingModels.RuleUIDLabel +} + +func isEqualMatcher(m amv2.Matcher) bool { + // If IsEqual is nil, it is considered to be true. + return (m.IsEqual == nil || *m.IsEqual) && (m.IsRegex == nil || !*m.IsRegex) } diff --git a/pkg/services/ngalert/models/silence_test.go b/pkg/services/ngalert/models/silence_test.go new file mode 100644 index 00000000000..a39881dd2ff --- /dev/null +++ b/pkg/services/ngalert/models/silence_test.go @@ -0,0 +1,50 @@ +package models + +import ( + "testing" + + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/stretchr/testify/assert" + + "github.com/grafana/alerting/models" + "github.com/grafana/grafana/pkg/util" +) + +func TestSilenceGetRuleUID(t *testing.T) { + testCases := []struct { + name string + silence Silence + expectedRuleUID *string + }{ + { + name: "silence with no rule UID", + silence: SilenceGen()(), + expectedRuleUID: nil, + }, + { + name: "silence with rule UID", + silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchEqual))(), + expectedRuleUID: util.Pointer("someuid"), + }, + { + name: "silence with rule UID Matcher but MatchNotEqual", + silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchNotEqual))(), + expectedRuleUID: nil, + }, + { + name: "silence with rule UID Matcher but MatchRegexp", + silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchRegexp))(), + expectedRuleUID: nil, + }, + { + name: "silence with rule UID Matcher but MatchNotRegexp", + silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchNotRegexp))(), + expectedRuleUID: nil, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedRuleUID, tt.silence.GetRuleUID(), "unexpected rule UID") + }) + } +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 27deff6c000..f3f37c3c4ce 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -9,7 +9,10 @@ import ( "testing" "time" + "github.com/go-openapi/strfmt" "github.com/grafana/grafana-plugin-sdk-go/data" + amv2 "github.com/prometheus/alertmanager/api/v2/models" + "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" @@ -903,3 +906,117 @@ func (n NotificationSettingsMutators) WithMuteTimeIntervals(muteTimeIntervals .. ns.MuteTimeIntervals = muteTimeIntervals } } + +// Silences + +// CopySilenceWith creates a deep copy of Silence and then applies mutators to it. +func CopySilenceWith(s Silence, mutators ...Mutator[Silence]) Silence { + c := CopySilence(s) + for _, mutator := range mutators { + mutator(&c) + } + return c +} + +// CopySilence creates a deep copy of Silence. +func CopySilence(s Silence) Silence { + c := Silence{ + Silence: amv2.Silence{}, + } + + if s.ID != nil { + c.ID = util.Pointer(*s.ID) + } + if s.Status != nil { + c.Status = util.Pointer(*s.Status) + } + if s.UpdatedAt != nil { + c.UpdatedAt = util.Pointer(*s.UpdatedAt) + } + if s.Silence.Comment != nil { + c.Silence.Comment = util.Pointer(*s.Silence.Comment) + } + if s.Silence.CreatedBy != nil { + c.Silence.CreatedBy = util.Pointer(*s.Silence.CreatedBy) + } + if s.Silence.EndsAt != nil { + c.Silence.EndsAt = util.Pointer(*s.Silence.EndsAt) + } + if s.Silence.StartsAt != nil { + c.Silence.StartsAt = util.Pointer(*s.Silence.StartsAt) + } + if s.Silence.Matchers != nil { + c.Silence.Matchers = CopyMatchers(s.Silence.Matchers) + } + + return c +} + +// CopyMatchers creates a deep copy of Matchers. +func CopyMatchers(matchers []*amv2.Matcher) []*amv2.Matcher { + copies := make([]*amv2.Matcher, len(matchers)) + for i, m := range matchers { + c := amv2.Matcher{} + if m.IsEqual != nil { + c.IsEqual = util.Pointer(*m.IsEqual) + } + if m.IsRegex != nil { + c.IsRegex = util.Pointer(*m.IsRegex) + } + if m.Name != nil { + c.Name = util.Pointer(*m.Name) + } + if m.Value != nil { + c.Value = util.Pointer(*m.Value) + } + copies[i] = &c + } + return copies +} + +// SilenceGen generates Silence using a base and mutators. +func SilenceGen(mutators ...Mutator[Silence]) func() Silence { + return func() Silence { + now := time.Now() + c := Silence{ + ID: util.Pointer(util.GenerateShortUID()), + Status: util.Pointer(amv2.SilenceStatus{State: util.Pointer(amv2.SilenceStatusStateActive)}), + UpdatedAt: util.Pointer(strfmt.DateTime(now.Add(time.Minute))), + Silence: amv2.Silence{ + Comment: util.Pointer(util.GenerateShortUID()), + CreatedBy: util.Pointer(util.GenerateShortUID()), + StartsAt: util.Pointer(strfmt.DateTime(now.Add(-time.Minute))), + EndsAt: util.Pointer(strfmt.DateTime(now.Add(time.Minute))), + Matchers: []*amv2.Matcher{{Name: util.Pointer(util.GenerateShortUID()), Value: util.Pointer(util.GenerateShortUID()), IsRegex: util.Pointer(false), IsEqual: util.Pointer(true)}}, + }, + } + for _, mutator := range mutators { + mutator(&c) + } + return c + } +} + +var ( + SilenceMuts = SilenceMutators{} +) + +type SilenceMutators struct{} + +func (n SilenceMutators) WithMatcher(name, value string, matchType labels.MatchType) Mutator[Silence] { + return func(s *Silence) { + m := amv2.Matcher{ + Name: &name, + Value: &value, + IsRegex: util.Pointer(matchType == labels.MatchRegexp || matchType == labels.MatchNotRegexp), + IsEqual: util.Pointer(matchType == labels.MatchRegexp || matchType == labels.MatchEqual), + } + s.Silence.Matchers = append(s.Silence.Matchers, &m) + } +} + +func (n SilenceMutators) WithEmptyId() Mutator[Silence] { + return func(s *Silence) { + s.ID = util.Pointer("") + } +} diff --git a/pkg/services/ngalert/notifier/compat.go b/pkg/services/ngalert/notifier/compat.go index 7d6580b9ec7..c809d927450 100644 --- a/pkg/services/ngalert/notifier/compat.go +++ b/pkg/services/ngalert/notifier/compat.go @@ -3,9 +3,10 @@ package notifier import ( "encoding/json" + "github.com/prometheus/alertmanager/config" + alertingNotify "github.com/grafana/alerting/notify" alertingTemplates "github.com/grafana/alerting/templates" - "github.com/prometheus/alertmanager/config" "github.com/grafana/grafana/pkg/components/simplejson" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -122,3 +123,29 @@ func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplate } return out } + +// Silence-specific compat functions to convert between grafana/alerting and model types. + +func GettableSilenceToSilence(s alertingNotify.GettableSilence) *models.Silence { + sil := models.Silence(s) + return &sil +} + +func GettableSilencesToSilences(silences alertingNotify.GettableSilences) []*models.Silence { + res := make([]*models.Silence, 0, len(silences)) + for _, sil := range silences { + res = append(res, GettableSilenceToSilence(*sil)) + } + return res +} + +func SilenceToPostableSilence(s models.Silence) *alertingNotify.PostableSilence { + var id string + if s.ID != nil { + id = *s.ID + } + return &alertingNotify.PostableSilence{ + ID: id, + Silence: s.Silence, + } +} diff --git a/pkg/services/ngalert/notifier/errors.go b/pkg/services/ngalert/notifier/errors.go new file mode 100644 index 00000000000..32618daab1c --- /dev/null +++ b/pkg/services/ngalert/notifier/errors.go @@ -0,0 +1,9 @@ +package notifier + +import "github.com/grafana/grafana/pkg/util/errutil" + +// WithPublicError sets the public message of an errutil error to the error message. +func WithPublicError(err errutil.Error) error { + err.PublicMessage = err.Error() + return err +} diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 80fa5dc155c..2132e7093bd 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -2,6 +2,7 @@ package notifier import ( "context" + "errors" "fmt" "sync" "time" @@ -9,6 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus" alertingCluster "github.com/grafana/alerting/cluster" + "github.com/grafana/grafana/pkg/util/errutil" alertingNotify "github.com/grafana/alerting/notify" @@ -29,6 +31,17 @@ var ( ErrAlertmanagerNotReady = fmt.Errorf("Alertmanager is not ready yet") ) +// errutil-based errors. +// TODO: Should completely replace the fmt.Errorf-based errors. +var ( + ErrAlertmanagerNotFound = errutil.NotFound("alerting.notifications.alertmanager.notFound") + ErrAlertmanagerConflict = errutil.Conflict("alerting.notifications.alertmanager.conflict") + + ErrSilenceNotFound = errutil.NotFound("alerting.notifications.silences.notFound") + ErrSilencesBadRequest = errutil.BadRequest("alerting.notifications.silences.badRequest") + ErrSilenceInternal = errutil.Internal("alerting.notifications.silences.internal") +) + //go:generate mockery --name Alertmanager --structname AlertmanagerMock --with-expecter --output alertmanager_mock --outpkg alertmanager_mock type Alertmanager interface { // Configuration @@ -400,25 +413,88 @@ func (moa *MultiOrgAlertmanager) AlertmanagerFor(orgID int64) (Alertmanager, err return orgAM, nil } -// CreateSilence creates a silence in the Alertmanager for the organization provided, returning the silence ID. It will -// also persist the silence state to the kvstore immediately after creating the silence. -func (moa *MultiOrgAlertmanager) CreateSilence(ctx context.Context, orgID int64, ps *alertingNotify.PostableSilence) (string, error) { - moa.alertmanagersMtx.RLock() - defer moa.alertmanagersMtx.RUnlock() - +// alertmanagerForOrg returns the Alertmanager instance for the organization provided. Should only be called when the +// caller has already locked the alertmanagersMtx. +// TODO: This should eventually replace AlertmanagerFor once the API layer has been refactored to not access the alertmanagers directly +// and AM route error handling has been fully moved to errorutil. +func (moa *MultiOrgAlertmanager) alertmanagerForOrg(orgID int64) (Alertmanager, error) { orgAM, existing := moa.alertmanagers[orgID] if !existing { - return "", ErrNoAlertmanagerForOrg + return nil, WithPublicError(ErrAlertmanagerNotFound.Errorf("Alertmanager does not exist for org %d", orgID)) } if !orgAM.Ready() { - return "", ErrAlertmanagerNotReady + return nil, WithPublicError(ErrAlertmanagerConflict.Errorf("Alertmanager is not ready for org %d", orgID)) + } + + return orgAM, nil +} + +// ListSilences lists silences for the organization provided. Currently, this is a pass-through to the Alertmanager +// implementation. +func (moa *MultiOrgAlertmanager) ListSilences(ctx context.Context, orgID int64, filter []string) ([]*models.Silence, error) { + moa.alertmanagersMtx.RLock() + defer moa.alertmanagersMtx.RUnlock() + + orgAM, err := moa.alertmanagerForOrg(orgID) + if err != nil { + return nil, err + } + + silences, err := orgAM.ListSilences(ctx, filter) + if err != nil { + if errors.Is(err, alertingNotify.ErrListSilencesBadPayload) { + return nil, WithPublicError(ErrSilencesBadRequest.Errorf("invalid filters: %w", err)) + } + return nil, WithPublicError(ErrSilenceInternal.Errorf("failed to list silences: %w", err)) + } + return GettableSilencesToSilences(silences), nil +} + +// GetSilence gets a silence for the organization and silence id provided. Currently, this is a pass-through to the +// Alertmanager implementation. +func (moa *MultiOrgAlertmanager) GetSilence(ctx context.Context, orgID int64, id string) (*models.Silence, error) { + moa.alertmanagersMtx.RLock() + defer moa.alertmanagersMtx.RUnlock() + + orgAM, err := moa.alertmanagerForOrg(orgID) + if err != nil { + return nil, err + } + + s, err := orgAM.GetSilence(ctx, id) + if err != nil { + if errors.Is(err, alertingNotify.ErrSilenceNotFound) { + return nil, WithPublicError(ErrSilenceNotFound.Errorf("silence %s not found", id)) + } + return nil, WithPublicError(ErrSilenceInternal.Errorf("failed to get silence: %w", err)) + } + + return GettableSilenceToSilence(s), nil +} + +// CreateSilence creates a silence in the Alertmanager for the organization provided, returning the silence ID. It will +// also persist the silence state to the kvstore immediately after creating the silence. +func (moa *MultiOrgAlertmanager) CreateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error) { + moa.alertmanagersMtx.RLock() + defer moa.alertmanagersMtx.RUnlock() + + orgAM, err := moa.alertmanagerForOrg(orgID) + if err != nil { + return "", err } // Need to create the silence in the AM first to get the silence ID. - silenceID, err := orgAM.CreateSilence(ctx, ps) + silenceID, err := orgAM.CreateSilence(ctx, SilenceToPostableSilence(ps)) if err != nil { - return "", err + if errors.Is(err, alertingNotify.ErrSilenceNotFound) { + return "", WithPublicError(ErrSilenceNotFound.Errorf("silence %v not found", ps.ID)) + } + + if errors.Is(err, alertingNotify.ErrCreateSilenceBadPayload) { + return "", WithPublicError(ErrSilencesBadRequest.Errorf("invalid silence: %w", err)) + } + return "", WithPublicError(ErrSilenceInternal.Errorf("failed to upsert silence: %w", err)) } err = moa.updateSilenceState(ctx, orgAM, orgID) @@ -429,26 +505,35 @@ func (moa *MultiOrgAlertmanager) CreateSilence(ctx context.Context, orgID int64, return silenceID, nil } +// UpdateSilence updates a silence in the Alertmanager for the organization provided, returning the silence ID. It will +// also persist the silence state to the kvstore immediately after creating the silence. +// Currently, this just calls CreateSilence as the underlying Alertmanager implementation upserts. +func (moa *MultiOrgAlertmanager) UpdateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error) { + if ps.ID == nil || *ps.ID == "" { // TODO: Alertmanager interface should probably include a method for updating silences. For now, we leak this implementation detail. + return "", WithPublicError(ErrSilencesBadRequest.Errorf("silence ID is required")) + } + return moa.CreateSilence(ctx, orgID, ps) +} + // DeleteSilence deletes a silence in the Alertmanager for the organization provided. It will also persist the silence // state to the kvstore immediately after deleting the silence. func (moa *MultiOrgAlertmanager) DeleteSilence(ctx context.Context, orgID int64, silenceID string) error { moa.alertmanagersMtx.RLock() defer moa.alertmanagersMtx.RUnlock() - orgAM, existing := moa.alertmanagers[orgID] - if !existing { - return ErrNoAlertmanagerForOrg - } - - if !orgAM.Ready() { - return ErrAlertmanagerNotReady - } - - err := orgAM.DeleteSilence(ctx, silenceID) + orgAM, err := moa.alertmanagerForOrg(orgID) if err != nil { return err } + err = orgAM.DeleteSilence(ctx, silenceID) + if err != nil { + if errors.Is(err, alertingNotify.ErrSilenceNotFound) { + return WithPublicError(ErrSilenceNotFound.Errorf("silence %s not found", silenceID)) + } + return WithPublicError(ErrSilenceInternal.Errorf("failed to delete silence %s: %w", silenceID, err)) + } + err = moa.updateSilenceState(ctx, orgAM, orgID) if err != nil { moa.logger.Warn("Failed to persist silence state on delete, will be corrected by next maintenance run", "orgID", orgID, "silenceID", silenceID, "error", err) diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go index af5062533e3..c8bae72ec12 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go @@ -6,12 +6,13 @@ import ( "testing" "time" - alertingNotify "github.com/grafana/alerting/notify" "github.com/prometheus/alertmanager/types" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/metrics" @@ -207,13 +208,13 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) { // First, let's try to request an Alertmanager from an org that doesn't exist. { - _, err := mam.AlertmanagerFor(5) - require.EqualError(t, err, ErrNoAlertmanagerForOrg.Error()) + _, err := mam.alertmanagerForOrg(5) + require.ErrorIs(t, err, ErrAlertmanagerNotFound) } // With an Alertmanager that exists, it responds correctly. { - am, err := mam.AlertmanagerFor(2) + am, err := mam.alertmanagerForOrg(2) require.NoError(t, err) internalAm, ok := am.(*alertmanager) require.True(t, ok) @@ -227,8 +228,8 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) { mam.orgStore.(*FakeOrgStore).orgs = []int64{1, 3} require.NoError(t, mam.LoadAndSyncAlertmanagersForOrgs(ctx)) { - _, err := mam.AlertmanagerFor(2) - require.EqualError(t, err, ErrNoAlertmanagerForOrg.Error()) + _, err := mam.alertmanagerForOrg(2) + require.ErrorIs(t, err, ErrAlertmanagerNotFound) } } @@ -253,7 +254,7 @@ func TestMultiOrgAlertmanager_ActivateHistoricalConfiguration(t *testing.T) { // Now let's save a new config for org 2. newConfig := `{"template_files":null,"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"templates":null,"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"some other name","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"},"secureSettings":null}]}]}}` - am, err := mam.AlertmanagerFor(2) + am, err := mam.alertmanagerForOrg(2) require.NoError(t, err) postable, err := Load([]byte(newConfig)) @@ -295,7 +296,7 @@ func TestMultiOrgAlertmanager_Silences(t *testing.T) { require.Len(t, mam.alertmanagers, 3) } - am, err := mam.AlertmanagerFor(1) + am, err := mam.alertmanagerForOrg(1) require.NoError(t, err) // Confirm no silences. @@ -315,10 +316,11 @@ func TestMultiOrgAlertmanager_Silences(t *testing.T) { require.Empty(t, v) // Create 2 silences. - sid, err := mam.CreateSilence(ctx, 1, GenSilence("test")) + gen := models.SilenceGen(models.SilenceMuts.WithEmptyId()) + sid, err := mam.CreateSilence(ctx, 1, gen()) require.NoError(t, err) require.NotEmpty(t, sid) - sid2, err := mam.CreateSilence(ctx, 1, GenSilence("test")) + sid2, err := mam.CreateSilence(ctx, 1, gen()) require.NoError(t, err) require.NotEmpty(t, sid2) diff --git a/pkg/services/ngalert/notifier/silence_svc.go b/pkg/services/ngalert/notifier/silence_svc.go new file mode 100644 index 00000000000..da01516b1f1 --- /dev/null +++ b/pkg/services/ngalert/notifier/silence_svc.go @@ -0,0 +1,127 @@ +package notifier + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// SilenceService is the authenticated service for managing alertmanager silences. +type SilenceService struct { + authz SilenceAccessControlService + xact transactionManager + log log.Logger + store SilenceStore +} + +// SilenceAccessControlService provides access control for silences. +type SilenceAccessControlService interface { + FilterByAccess(ctx context.Context, user identity.Requester, silences ...*models.Silence) ([]*models.Silence, error) + AuthorizeReadSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error + AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error + AuthorizeUpdateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error +} + +// SilenceStore is the interface for storing and retrieving silences. Currently, this is implemented by +// MultiOrgAlertmanager but should eventually be replaced with an actual store. +type SilenceStore interface { + ListSilences(ctx context.Context, orgID int64, filter []string) ([]*models.Silence, error) + GetSilence(ctx context.Context, orgID int64, id string) (*models.Silence, error) + CreateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error) + UpdateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error) + DeleteSilence(ctx context.Context, orgID int64, id string) error +} + +func NewSilenceService( + authz SilenceAccessControlService, + xact transactionManager, + log log.Logger, + store SilenceStore, +) *SilenceService { + return &SilenceService{ + authz: authz, + xact: xact, + log: log, + store: store, + } +} + +// GetSilence retrieves a silence by its ID. +func (s *SilenceService) GetSilence(ctx context.Context, user identity.Requester, silenceID string) (*models.Silence, error) { + gettableSilence, err := s.store.GetSilence(ctx, user.GetOrgID(), silenceID) + if err != nil { + return nil, err + } + + if err := s.authz.AuthorizeReadSilence(ctx, user, gettableSilence); err != nil { + return nil, err + } + + return gettableSilence, nil +} + +// ListSilences retrieves all silences that match the given filter. This will include all rule-specific silences that +// the user has access to as well as all general silences. +func (s *SilenceService) ListSilences(ctx context.Context, user identity.Requester, filter []string) ([]*models.Silence, error) { + gettableSilences, err := s.store.ListSilences(ctx, user.GetOrgID(), filter) + if err != nil { + return nil, err + } + + return s.authz.FilterByAccess(ctx, user, gettableSilences...) +} + +// CreateSilence creates a new silence. +// For rule-specific silences, the user needs permission to create silences in the folder that the associated rule is in. +// For general silences, the user needs broader permissions. +func (s *SilenceService) CreateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error) { + if err := s.authz.AuthorizeCreateSilence(ctx, user, &ps); err != nil { + return "", err + } + + silenceId, err := s.store.CreateSilence(ctx, user.GetOrgID(), ps) + if err != nil { + return "", err + } + + return silenceId, nil +} + +// UpdateSilence updates an existing silence. +// For rule-specific silences, the user needs permission to update silences in the folder that the associated rule is in. +// For general silences, the user needs broader permissions. +func (s *SilenceService) UpdateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error) { + if err := s.authz.AuthorizeUpdateSilence(ctx, user, &ps); err != nil { + return "", err + } + + silenceId, err := s.store.UpdateSilence(ctx, user.GetOrgID(), ps) + if err != nil { + return "", err + } + + return silenceId, nil +} + +// DeleteSilence deletes a silence by its ID. +// For rule-specific silences, the user needs permission to update silences in the folder that the associated rule is in. +// For general silences, the user needs broader permissions. +func (s *SilenceService) DeleteSilence(ctx context.Context, user identity.Requester, silenceID string) error { + silence, err := s.GetSilence(ctx, user, silenceID) + if err != nil { + return err + } + + if err := s.authz.AuthorizeUpdateSilence(ctx, user, silence); err != nil { + return err + } + + err = s.store.DeleteSilence(ctx, user.GetOrgID(), silenceID) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index e2edc7811a6..3b1a6ea965a 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -7,18 +7,14 @@ import ( "errors" "fmt" "io" - "math/rand" "testing" "time" - "github.com/go-openapi/strfmt" "github.com/matttproud/golang_protobuf_extensions/pbutil" - amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/common/model" - apimodels "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/store" @@ -361,24 +357,3 @@ func createNotificationLog(groupKey string, receiverName string, sentAt, expires ExpiresAt: expiresAt, } } - -func GenSilence(createdBy string) *apimodels.PostableSilence { - starts := strfmt.DateTime(time.Now().Add(time.Duration(rand.Int63n(9)+1) * time.Second)) - ends := strfmt.DateTime(time.Now().Add(time.Duration(rand.Int63n(9)+10) * time.Second)) - comment := "test comment" - isEqual := true - name := "test" - value := "test" - isRegex := false - matchers := amv2.Matchers{&amv2.Matcher{IsEqual: &isEqual, Name: &name, Value: &value, IsRegex: &isRegex}} - - return &apimodels.PostableSilence{ - Silence: amv2.Silence{ - Comment: &comment, - CreatedBy: &createdBy, - Matchers: matchers, - StartsAt: &starts, - EndsAt: &ends, - }, - } -} diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index fff6d177e0e..3b6c6165c9f 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -507,7 +507,8 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) { require.Equal(t, 0, len(silences)) // Creating a silence should succeed. - testSilence := notifier.GenSilence("test") + gen := ngmodels.SilenceGen(ngmodels.SilenceMuts.WithEmptyId()) + testSilence := notifier.SilenceToPostableSilence(gen()) id, err := am.CreateSilence(context.Background(), testSilence) require.NoError(t, err) require.NotEmpty(t, id) @@ -523,7 +524,7 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) { require.Error(t, err) // After creating another silence, the total amount should be 2. - testSilence2 := notifier.GenSilence("test") + testSilence2 := notifier.SilenceToPostableSilence(gen()) id, err = am.CreateSilence(context.Background(), testSilence2) require.NoError(t, err) require.NotEmpty(t, id) @@ -546,7 +547,7 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) { if *s.ID == testSilence.ID { require.Equal(t, *s.Status.State, "expired") } else { - require.Equal(t, *s.Status.State, "pending") + require.Equal(t, *s.Status.State, "active") } } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 0ae77f063d5..09dfc1e0fad 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -11,6 +11,8 @@ import ( "golang.org/x/exp/maps" "golang.org/x/exp/slices" + "xorm.io/xorm" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/auth/identity" @@ -23,7 +25,6 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/util" - "xorm.io/xorm" ) // AlertRuleMaxTitleLength is the maximum length of the alert rule title @@ -769,3 +770,20 @@ func ruleConstraintViolationToErr(rule ngmodels.AlertRule, err error) error { return ngmodels.ErrAlertRuleConflict(rule, err) } } + +// GetNamespacesByRuleUID returns a map of rule UIDs to their namespace UID. +func (st DBstore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) { + result := make(map[string]string) + err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { + var rules []ngmodels.AlertRule + err := sess.Table(ngmodels.AlertRule{}).Select("uid, namespace_uid").Where("org_id = ?", orgID).In("uid", uids).Find(&rules) + if err != nil { + return err + } + for _, rule := range rules { + result[rule.UID] = rule.NamespaceUID + } + return nil + }) + return result, err +} diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 4958c69b5f3..7722b403949 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -809,6 +809,57 @@ func TestIntegrationListNotificationSettings(t *testing.T) { }) } +func TestIntegrationGetNamespacesByRuleUID(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.UnifiedAlerting.BaseInterval = 1 * time.Second + store := &DBstore{ + SQLStore: sqlStore, + FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()), + Logger: log.New("test-dbstore"), + Cfg: cfg.UnifiedAlerting, + } + + rules := models.RuleGen.With(models.RuleMuts.WithOrgID(1)).GenerateMany(5) + _, err := store.InsertAlertRules(context.Background(), rules) + require.NoError(t, err) + + uids := make([]string, 0, len(rules)) + for _, rule := range rules { + uids = append(uids, rule.UID) + } + + result, err := store.GetNamespacesByRuleUID(context.Background(), 1, uids...) + require.NoError(t, err) + require.Len(t, result, len(rules)) + for _, rule := range rules { + if !assert.Contains(t, result, rule.UID) { + continue + } + assert.EqualValues(t, rule.NamespaceUID, result[rule.UID]) + } + + // Now test with a subset of uids. + subset := uids[:3] + result, err = store.GetNamespacesByRuleUID(context.Background(), 1, subset...) + require.NoError(t, err) + require.Len(t, result, len(subset)) + for _, uid := range subset { + if !assert.Contains(t, result, uid) { + continue + } + for _, rule := range rules { + if rule.UID == uid { + assert.EqualValues(t, rule.NamespaceUID, result[uid]) + } + } + } +} + // createAlertRule creates an alert rule in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. func createRule(t *testing.T, store *DBstore, generator *models.AlertRuleGenerator) *models.AlertRule { diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 9f8bea0c31e..1f91c52fcd9 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -353,3 +353,28 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org func (f *RuleStore) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) { return 0, nil } + +func (f *RuleStore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) { + f.mtx.Lock() + defer f.mtx.Unlock() + + namespacesMap := make(map[string]string) + + rules, ok := f.Rules[orgID] + if !ok { + return namespacesMap, nil + } + + uidFilter := make(map[string]struct{}, len(uids)) + for _, uid := range uids { + uidFilter[uid] = struct{}{} + } + + for _, rule := range rules { + if _, ok := uidFilter[rule.UID]; ok { + namespacesMap[rule.UID] = rule.NamespaceUID + } + } + + return namespacesMap, nil +} diff --git a/pkg/tests/api/alerting/api_alertmanager_silence_test.go b/pkg/tests/api/alerting/api_alertmanager_silence_test.go new file mode 100644 index 00000000000..1917b397ee7 --- /dev/null +++ b/pkg/tests/api/alerting/api_alertmanager_silence_test.go @@ -0,0 +1,422 @@ +package alerting + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + 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/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util" +) + +func TestIntegrationSilenceAuth(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + adminApiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + // Create the namespace we'll save our alerts to. + f1 := folder.Folder{ + UID: util.GenerateShortUID(), + Title: "Folder 1", + } + adminApiClient.CreateFolder(t, f1.UID, f1.Title) + f2 := folder.Folder{ + UID: util.GenerateShortUID(), + Title: "Folder 2", + } + adminApiClient.CreateFolder(t, f2.UID, f2.Title) + + group1 := generateAlertRuleGroup(1, alertRuleGen()) + group2 := generateAlertRuleGroup(1, alertRuleGen()) + + respModel, status, _ := adminApiClient.PostRulesGroupWithStatus(t, f1.UID, &group1) + require.Equal(t, http.StatusAccepted, status) + ruleInFolder1UID := respModel.Created[0] + respModel, status, _ = adminApiClient.PostRulesGroupWithStatus(t, f2.UID, &group2) + require.Equal(t, http.StatusAccepted, status) + ruleInFolder2UID := respModel.Created[0] + + type silenceAction string + const ( + readSilence silenceAction = "read" + createSilence silenceAction = "create" + updateSilence silenceAction = "update" + deleteSilence silenceAction = "delete" + ) + + type silenceType string + const ( + generalSilence silenceType = "generalSilence" + ruleSilenceInFolder1 silenceType = "ruleSilenceInFolder1" + ruleSilenceInFolder2 silenceType = "ruleSilenceInFolder2" + ) + + silenceGens := map[silenceType]func() ngmodels.Silence{ + generalSilence: ngmodels.SilenceGen(), + ruleSilenceInFolder1: ngmodels.SilenceGen(ngmodels.SilenceMuts.WithMatcher(models.RuleUIDLabel, ruleInFolder1UID, labels.MatchEqual)), + ruleSilenceInFolder2: ngmodels.SilenceGen(ngmodels.SilenceMuts.WithMatcher(models.RuleUIDLabel, ruleInFolder2UID, labels.MatchEqual)), + } + + defaultStatus := map[silenceAction]map[bool]int{ + updateSilence: {true: http.StatusAccepted, false: http.StatusForbidden}, + deleteSilence: {true: http.StatusOK, false: http.StatusForbidden}, + createSilence: {true: http.StatusAccepted, false: http.StatusForbidden}, + readSilence: {true: http.StatusOK, false: http.StatusForbidden}, + } + + testCases := []struct { + name string + orgRole org.RoleType // default RoleNone + permissions []resourcepermissions.SetResourcePermissionCommand + defaultAllowed bool // Default allowed/forbidden for actions not in statusExceptions. + statusExceptions map[silenceType]map[silenceAction]int // Exceptions to defaultAllowed. + listContents []silenceType // nil = forbidden. + }{ + // OSS Builtins + { + name: "Viewer permissions", + orgRole: org.RoleViewer, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder1: {readSilence: http.StatusOK}, + ruleSilenceInFolder2: {readSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + { + name: "Viewer permissions with elevated access to folder1", + orgRole: org.RoleViewer, + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: ossaccesscontrol.FolderEditActions, Resource: "folders", ResourceAttribute: "uid", ResourceID: f1.UID}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder1: {readSilence: http.StatusOK, updateSilence: http.StatusAccepted, createSilence: http.StatusAccepted, deleteSilence: http.StatusOK}, + ruleSilenceInFolder2: {readSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + { + name: "Editor permissions", + orgRole: org.RoleEditor, + defaultAllowed: true, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + { + name: "Admin permissions", + orgRole: org.RoleAdmin, + defaultAllowed: true, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + // RBAC + { + name: "No permissions", + orgRole: org.RoleNone, + }, + { + name: "Global read", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{accesscontrol.ActionAlertingInstanceRead}}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder1: {readSilence: http.StatusOK}, + ruleSilenceInFolder2: {readSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + { + name: "Global read + create permissions", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingInstanceRead, + accesscontrol.ActionAlertingInstanceCreate, + }}, + }, + defaultAllowed: true, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {updateSilence: http.StatusForbidden, deleteSilence: http.StatusForbidden}, + ruleSilenceInFolder1: {updateSilence: http.StatusForbidden, deleteSilence: http.StatusForbidden}, + ruleSilenceInFolder2: {updateSilence: http.StatusForbidden, deleteSilence: http.StatusForbidden}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + { + name: "Global read + update permissions", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingInstanceRead, + accesscontrol.ActionAlertingInstanceUpdate, + }}, + }, + defaultAllowed: true, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {createSilence: http.StatusForbidden}, + ruleSilenceInFolder1: {createSilence: http.StatusForbidden}, + ruleSilenceInFolder2: {createSilence: http.StatusForbidden}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + { + name: "Global read + update + create permissions", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingInstanceRead, + accesscontrol.ActionAlertingInstanceUpdate, + accesscontrol.ActionAlertingInstanceCreate, + }}, + }, + defaultAllowed: true, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1, ruleSilenceInFolder2}, + }, + { + name: "Global update + create permissions, missing read", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingInstanceUpdate, + accesscontrol.ActionAlertingInstanceCreate, + }}, + }, + }, + { + name: "Silence read in folder1", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{accesscontrol.ActionAlertingSilencesRead}, Resource: "folders", ResourceAttribute: "uid", ResourceID: f1.UID}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder1: {readSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1}, + }, + { + name: "Silence read in folder2", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{accesscontrol.ActionAlertingSilencesRead}, Resource: "folders", ResourceAttribute: "uid", ResourceID: f2.UID}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder2: {readSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder2}, + }, + { + name: "Silence read + create in folder1", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingSilencesRead, + accesscontrol.ActionAlertingSilencesCreate, + }, Resource: "folders", ResourceAttribute: "uid", ResourceID: f1.UID}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder1: {readSilence: http.StatusOK, createSilence: http.StatusAccepted}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1}, + }, + { + name: "Silence read + write in folder1", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingSilencesRead, + accesscontrol.ActionAlertingSilencesWrite, + }, Resource: "folders", ResourceAttribute: "uid", ResourceID: f1.UID}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder1: {readSilence: http.StatusOK, updateSilence: http.StatusAccepted, deleteSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1}, + }, + { + name: "Silence read + write + create in folder1", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingSilencesRead, + accesscontrol.ActionAlertingSilencesWrite, + accesscontrol.ActionAlertingSilencesCreate, + }, Resource: "folders", ResourceAttribute: "uid", ResourceID: f1.UID}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + ruleSilenceInFolder1: {readSilence: http.StatusOK, updateSilence: http.StatusAccepted, createSilence: http.StatusAccepted, deleteSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence, ruleSilenceInFolder1}, + }, + { + name: "Silence read + write + create in other folder", + permissions: []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{ + accesscontrol.ActionAlertingSilencesRead, + accesscontrol.ActionAlertingSilencesWrite, + accesscontrol.ActionAlertingSilencesCreate, + }, Resource: "folders", ResourceAttribute: "uid", ResourceID: "unknown"}, + }, + statusExceptions: map[silenceType]map[silenceAction]int{ + generalSilence: {readSilence: http.StatusOK}, + }, + listContents: []silenceType{generalSilence}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + randomLogin := util.GenerateShortUID() + orgRole := org.RoleNone + if tt.orgRole != "" { + orgRole = tt.orgRole + } + testUserId := createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(orgRole), + Password: user.Password(randomLogin), + Login: randomLogin, + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, randomLogin, randomLogin) + + // Set permissions. + asService := resourcepermissions.NewActionSetService() + permissionsStore := resourcepermissions.NewStore(env.SQLStore, featuremgmt.WithFeatures(), &asService) + for _, cmd := range tt.permissions { + _, err := permissionsStore.SetUserResourcePermission( + context.Background(), + 1, + accesscontrol.User{ID: testUserId}, + cmd, + nil, + ) + require.NoError(t, err) + } + apiClient.ReloadCachedPermissions(t) + + expectedStatus := func(sType silenceType, action silenceAction) int { + expectedStatus, ok := defaultStatus[action][tt.defaultAllowed] + require.True(t, ok, "No default status for action") + if st, ok := tt.statusExceptions[sType][action]; ok { + expectedStatus = st + } + return expectedStatus + } + + persistSilence := func(gen func() ngmodels.Silence) apimodels.PostableSilence { + silence := *notifier.SilenceToPostableSilence(gen()) + silence.ID = "" + okBody, status, _ := adminApiClient.PostSilence(t, silence) + require.Equal(t, http.StatusAccepted, status) + require.NotEmpty(t, okBody.SilenceID) + silence.ID = okBody.SilenceID + return silence + } + + tests := map[silenceAction]func(func() ngmodels.Silence, silenceType) (int, string){ + readSilence: func(gen func() ngmodels.Silence, sType silenceType) (int, string) { + silence := persistSilence(gen) + _, status, body := apiClient.GetSilence(t, silence.ID) + return status, body + }, + createSilence: func(gen func() ngmodels.Silence, sType silenceType) (int, string) { + silence := *notifier.SilenceToPostableSilence(gen()) + silence.ID = "" + _, status, body := apiClient.PostSilence(t, silence) + return status, body + }, + updateSilence: func(gen func() ngmodels.Silence, sType silenceType) (int, string) { + silence := persistSilence(gen) + _, status, body := apiClient.PostSilence(t, silence) + return status, body + }, + deleteSilence: func(gen func() ngmodels.Silence, sType silenceType) (int, string) { + silence := persistSilence(gen) + _, status, body := apiClient.DeleteSilence(t, silence.ID) + return status, body + }, + } + + for action, test := range tests { + t.Run(string(action), func(t *testing.T) { + for sType, gen := range silenceGens { + expected := expectedStatus(sType, action) + t.Run(fmt.Sprintf("Silence: %s, Access: %d", sType, expected), func(t *testing.T) { + status, body := test(gen, sType) + t.Log(body) + require.Equal(t, expected, status) + }) + } + }) + } + + t.Run("List contents", func(t *testing.T) { + ids := make(map[silenceType]string) + idToStype := make(map[string]silenceType) + + // We Create new silences with a unique label. This is both to test the filter param and to + // simplify the test by having a known set of silences to list. + filterLabel := util.GenerateShortUID() + for sType, gen := range silenceGens { + genWithFilterLabels := func() ngmodels.Silence { + return ngmodels.CopySilenceWith(gen(), ngmodels.SilenceMuts.WithMatcher(filterLabel, filterLabel, labels.MatchEqual)) + } + silence := persistSilence(genWithFilterLabels) + ids[sType] = silence.ID + idToStype[silence.ID] = sType + } + silences, status, body := apiClient.GetSilences(t, fmt.Sprintf("%s=%s", filterLabel, filterLabel)) + t.Log(body) + if tt.listContents == nil { + require.Equal(t, http.StatusForbidden, status) + return + } + require.Equal(t, http.StatusOK, status) + + idsInBody := make(map[string]struct{}) + for _, s := range silences { + idsInBody[*s.ID] = struct{}{} + } + + for _, sType := range tt.listContents { + id, ok := ids[sType] + require.True(t, ok) + assert.Containsf(t, idsInBody, id, "Silence of type %s not found in list", sType) + } + + for _, s := range silences { + sType, ok := idToStype[*s.ID] + require.True(t, ok, "Unknown listed silence %s", *s.ID) + assert.Containsf(t, tt.listContents, sType, "Silence of type %s should not be found in list", sType) + } + assert.Len(t, silences, len(tt.listContents), "Listed silences count mismatch") + }) + }) + } +} diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 653def960c5..539645fbb8f 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -1996,13 +1996,18 @@ func TestIntegrationAlertmanagerCreateSilence(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - silenceID, err := client.PostSilence(t, tc.silence) + silenceOkBody, status, body := client.PostSilence(t, tc.silence) + t.Log(body) if tc.expErr != "" { - require.EqualError(t, err, tc.expErr) - require.Empty(t, silenceID) + assert.NotEqual(t, http.StatusAccepted, status) + + var validationError errutil.PublicError + assert.NoError(t, json.Unmarshal([]byte(body), &validationError)) + assert.Contains(t, validationError.Message, tc.expErr) + assert.Empty(t, silenceOkBody.SilenceID) } else { - require.NoError(t, err) - require.NotEmpty(t, silenceID) + assert.Equal(t, http.StatusAccepted, status) + assert.NotEmpty(t, silenceOkBody.SilenceID) } }) } diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 208e7856a38..7f7a94df8c4 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -454,39 +454,50 @@ func (a apiClient) DeleteRulesGroup(t *testing.T, folder string, group string) ( return resp.StatusCode, string(b) } -func (a apiClient) PostSilence(t *testing.T, s apimodels.PostableSilence) (string, error) { +func (a apiClient) PostSilence(t *testing.T, s apimodels.PostableSilence) (apimodels.PostSilencesOKBody, int, string) { t.Helper() b, err := json.Marshal(s) require.NoError(t, err) - u := fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silences", a.url) - req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b)) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silences", a.url), bytes.NewReader(b)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") + return sendRequest[apimodels.PostSilencesOKBody](t, req, http.StatusAccepted) +} - client := &http.Client{} - resp, err := client.Do(req) +func (a apiClient) GetSilence(t *testing.T, id string) (apimodels.GettableSilence, int, string) { + t.Helper() + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silence/%s", a.url, id), nil) require.NoError(t, err) - require.NotNil(t, resp) + return sendRequest[apimodels.GettableSilence](t, req, http.StatusOK) +} - defer func() { - _ = resp.Body.Close() - }() - b, err = io.ReadAll(resp.Body) +func (a apiClient) GetSilences(t *testing.T, filters ...string) (apimodels.GettableSilences, int, string) { + t.Helper() + + u, err := url.Parse(fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silences", a.url)) require.NoError(t, err) - - data := struct { - SilenceID string `json:"silenceID"` - Message string `json:"message"` - }{} - require.NoError(t, json.Unmarshal(b, &data)) - - if resp.StatusCode == http.StatusAccepted { - return data.SilenceID, nil + if len(filters) > 0 { + u.RawQuery = url.Values{"filter": filters}.Encode() } - return "", errors.New(data.Message) + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + require.NoError(t, err) + + return sendRequest[apimodels.GettableSilences](t, req, http.StatusOK) +} + +func (a apiClient) DeleteSilence(t *testing.T, id string) (any, int, string) { + t.Helper() + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silence/%s", a.url, id), nil) + require.NoError(t, err) + + type dynamic struct { + Message string `json:"message"` + } + + return sendRequest[dynamic](t, req, http.StatusOK) } func (a apiClient) GetRulesGroup(t *testing.T, folder string, group string) apimodels.RuleGroupConfigResponse {