diff --git a/pkg/services/ngalert/accesscontrol/silences.go b/pkg/services/ngalert/accesscontrol/silences.go index 178e5ffa4d0..9d9666b674a 100644 --- a/pkg/services/ngalert/accesscontrol/silences.go +++ b/pkg/services/ngalert/accesscontrol/silences.go @@ -9,6 +9,7 @@ import ( 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" ) const ( @@ -78,10 +79,6 @@ var ( } ) -type Silence interface { - GetRuleUID() *string -} - type RuleUIDToNamespaceStore interface { GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) } @@ -104,7 +101,7 @@ func NewSilenceService(ac ac.AccessControl, store RuleUIDToNamespaceStore) *Sile // Global silence (one that is not attached to a particular rule) is considered available to all users. // For silences that are not attached to a rule, are checked against authorization. // This method is more preferred when many silences need to be checked. -func (s SilenceService) FilterByAccess(ctx context.Context, user identity.Requester, silences ...Silence) ([]Silence, error) { +func (s SilenceService) FilterByAccess(ctx context.Context, user identity.Requester, silences ...*models.Silence) ([]*models.Silence, error) { canAll, err := s.HasAccess(ctx, user, readAllSilencesEvaluator) if err != nil || canAll { // return early if user can either read all silences or there is an error return silences, err @@ -113,8 +110,8 @@ func (s SilenceService) FilterByAccess(ctx context.Context, user identity.Reques if err != nil || !canSome { return nil, err } - result := make([]Silence, 0, len(silences)) - silencesByRuleUID := make(map[string][]Silence, len(silences)) + result := make([]*models.Silence, 0, len(silences)) + silencesByRuleUID := make(map[string][]*models.Silence, len(silences)) for _, silence := range silences { ruleUID := silence.GetRuleUID() if ruleUID == nil { // if this is a general silence @@ -154,7 +151,7 @@ func (s SilenceService) FilterByAccess(ctx context.Context, user identity.Reques } // AuthorizeReadSilence checks if user has access to read a silence -func (s SilenceService) AuthorizeReadSilence(ctx context.Context, user identity.Requester, silence Silence) error { +func (s SilenceService) AuthorizeReadSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error { canAll, err := s.HasAccess(ctx, user, readAllSilencesEvaluator) if canAll || err != nil { // return early if user can either read all silences or there is error return err @@ -186,7 +183,7 @@ func (s SilenceService) AuthorizeReadSilence(ctx context.Context, user identity. } // AuthorizeCreateSilence checks if user has access to create a silence. Returns ErrAuthorizationBase if user is not authorized -func (s SilenceService) AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence Silence) error { +func (s SilenceService) AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error { canAny, err := s.HasAccess(ctx, user, createAnySilenceEvaluator) if err != nil || canAny { // return early if user can either create any silence or there is an error @@ -215,7 +212,7 @@ func (s SilenceService) AuthorizeCreateSilence(ctx context.Context, user identit } // AuthorizeUpdateSilence checks if user has access to update\expire a silence. Returns ErrAuthorizationBase if user is not authorized -func (s SilenceService) AuthorizeUpdateSilence(ctx context.Context, user identity.Requester, silence Silence) error { +func (s SilenceService) AuthorizeUpdateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error { canAny, err := s.HasAccess(ctx, user, updateAnySilenceEvaluator) if err != nil || canAny { // return early if user can either update any silence or there is an error diff --git a/pkg/services/ngalert/accesscontrol/silences_test.go b/pkg/services/ngalert/accesscontrol/silences_test.go index 1d85886032b..570c51f4773 100644 --- a/pkg/services/ngalert/accesscontrol/silences_test.go +++ b/pkg/services/ngalert/accesscontrol/silences_test.go @@ -11,6 +11,7 @@ import ( 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" ) @@ -18,15 +19,15 @@ import ( var orgID = rand.Int63() func TestFilterByAccess(t *testing.T) { - global := testSilence{ID: "global", RuleUID: nil} - ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")} + global := testSilence("global", nil) + ruleSilence1 := testSilence("rule-1", utils.Pointer("rule-1-uid")) folder1 := "rule-1-folder-uid" folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1) - ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")} + ruleSilence2 := testSilence("rule-2", utils.Pointer("rule-2-uid")) folder2 := "rule-2-folder-uid" - notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")} + notFoundRule := testSilence("unknown-rule", utils.Pointer("unknown-rule-uid")) - silences := []Silence{ + silences := []*models.Silence{ global, ruleSilence1, ruleSilence2, @@ -36,19 +37,19 @@ func TestFilterByAccess(t *testing.T) { testCases := []struct { name string user identity.Requester - expected []Silence + expected []*models.Silence expectedDbAccess bool }{ { name: "no silence access, empty list", user: newUser(), - expected: []Silence{}, + expected: []*models.Silence{}, expectedDbAccess: false, }, { name: "instance reader should get all", user: newUser(ac.Permission{Action: instancesRead}), - expected: []Silence{ + expected: []*models.Silence{ global, ruleSilence1, ruleSilence2, @@ -59,7 +60,7 @@ func TestFilterByAccess(t *testing.T) { { name: "silence reader should get global + folder", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}), - expected: []Silence{ + expected: []*models.Silence{ global, ruleSilence1, }, @@ -71,8 +72,8 @@ func TestFilterByAccess(t *testing.T) { ac := &recordingAccessControlFake{} store := &fakeRuleUIDToNamespaceStore{ Response: map[string]string{ - *ruleSilence1.RuleUID: folder1, - *ruleSilence2.RuleUID: folder2, + *ruleSilence1.GetRuleUID(): folder1, + *ruleSilence2.GetRuleUID(): folder2, }, } svc := NewSilenceService(ac, store) @@ -93,60 +94,60 @@ func TestFilterByAccess(t *testing.T) { } func TestAuthorizeReadSilence(t *testing.T) { - global := testSilence{ID: "global", RuleUID: nil} - ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")} + global := testSilence("global", nil) + ruleSilence1 := testSilence("rule-1", utils.Pointer("rule-1-uid")) folder1 := "rule-1-folder-uid" folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1) - ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")} + ruleSilence2 := testSilence("rule-2", utils.Pointer("rule-2-uid")) folder2 := "rule-2-folder-uid" - notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")} + notFoundRule := testSilence("unknown-rule", utils.Pointer("unknown-rule-uid")) testCases := []struct { name string user identity.Requester - silence []testSilence + silence []*models.Silence expectedErr error expectedDbAccess bool }{ { name: "not authorized without permissions", user: newUser(), - silence: []testSilence{global, ruleSilence1, notFoundRule}, + silence: []*models.Silence{global, ruleSilence1, notFoundRule}, expectedErr: ErrAuthorizationBase, expectedDbAccess: false, }, { name: "instance reader can read any silence", user: newUser(ac.Permission{Action: instancesRead}), - silence: []testSilence{global, ruleSilence1, notFoundRule}, + silence: []*models.Silence{global, ruleSilence1, notFoundRule}, expectedErr: nil, expectedDbAccess: false, }, { name: "silence reader can read global", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}), - silence: []testSilence{global}, + silence: []*models.Silence{global}, expectedErr: nil, expectedDbAccess: false, }, { name: "silence reader can read from allowed folder", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}), - silence: []testSilence{ruleSilence1}, + silence: []*models.Silence{ruleSilence1}, expectedErr: nil, expectedDbAccess: true, }, { name: "silence reader cannot read from other folders", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}), - silence: []testSilence{ruleSilence2}, + silence: []*models.Silence{ruleSilence2}, expectedErr: ErrAuthorizationBase, expectedDbAccess: true, }, { name: "silence reader cannot read unknown rule", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}), - silence: []testSilence{notFoundRule}, + silence: []*models.Silence{notFoundRule}, expectedErr: ErrAuthorizationBase, expectedDbAccess: true, }, @@ -155,12 +156,12 @@ func TestAuthorizeReadSilence(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { for _, silence := range testCase.silence { - t.Run(silence.ID, func(t *testing.T) { + t.Run(*silence.ID, func(t *testing.T) { ac := &recordingAccessControlFake{} store := &fakeRuleUIDToNamespaceStore{ Response: map[string]string{ - *ruleSilence1.RuleUID: folder1, - *ruleSilence2.RuleUID: folder2, + *ruleSilence1.GetRuleUID(): folder1, + *ruleSilence2.GetRuleUID(): folder2, }, } svc := NewSilenceService(ac, store) @@ -183,16 +184,16 @@ func TestAuthorizeReadSilence(t *testing.T) { } func TestAuthorizeCreateSilence(t *testing.T) { - global := testSilence{ID: "global", RuleUID: nil} - ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")} + global := testSilence("global", nil) + ruleSilence1 := testSilence("rule-1", utils.Pointer("rule-1-uid")) folder1 := "rule-1-folder-uid" folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1) - ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")} + ruleSilence2 := testSilence("rule-2", utils.Pointer("rule-2-uid")) folder2 := "rule-2-folder-uid" folder2Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2) - notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")} + notFoundRule := testSilence("unknown-rule", utils.Pointer("unknown-rule-uid")) - silences := []testSilence{ + silences := []*models.Silence{ global, ruleSilence1, ruleSilence2, @@ -208,7 +209,7 @@ func TestAuthorizeCreateSilence(t *testing.T) { user identity.Requester expectedErr error expectedDbAccess bool - overrides map[testSilence]override + overrides map[*models.Silence]override }{ { name: "not authorized without permissions", @@ -243,7 +244,7 @@ func TestAuthorizeCreateSilence(t *testing.T) { { name: "instance read + silence create", user: newUser(ac.Permission{Action: silenceCreate, Scope: folder1Scope}, ac.Permission{Action: instancesRead}), - overrides: map[testSilence]override{ + overrides: map[*models.Silence]override{ global: { expectedErr: ErrAuthorizationBase, expectedDbAccess: false, @@ -259,7 +260,7 @@ func TestAuthorizeCreateSilence(t *testing.T) { { name: "silence read + instance create", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesCreate}), - overrides: map[testSilence]override{ + overrides: map[*models.Silence]override{ global: { expectedErr: nil, expectedDbAccess: false, @@ -275,7 +276,7 @@ func TestAuthorizeCreateSilence(t *testing.T) { { name: "silence read + create", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceCreate, Scope: folder1Scope}), - overrides: map[testSilence]override{ + overrides: map[*models.Silence]override{ global: { expectedErr: ErrAuthorizationBase, expectedDbAccess: false, @@ -299,12 +300,12 @@ func TestAuthorizeCreateSilence(t *testing.T) { expectedErr = s.expectedErr expectedDbAccess = s.expectedDbAccess } - t.Run(silence.ID, func(t *testing.T) { + t.Run(*silence.ID, func(t *testing.T) { ac := &recordingAccessControlFake{} store := &fakeRuleUIDToNamespaceStore{ Response: map[string]string{ - *ruleSilence1.RuleUID: folder1, - *ruleSilence2.RuleUID: folder2, + *ruleSilence1.GetRuleUID(): folder1, + *ruleSilence2.GetRuleUID(): folder2, }, } svc := NewSilenceService(ac, store) @@ -328,16 +329,16 @@ func TestAuthorizeCreateSilence(t *testing.T) { } func TestAuthorizeUpdateSilence(t *testing.T) { - global := testSilence{ID: "global", RuleUID: nil} - ruleSilence1 := testSilence{ID: "rule-1", RuleUID: utils.Pointer("rule-1-uid")} + global := testSilence("global", nil) + ruleSilence1 := testSilence("rule-1", utils.Pointer("rule-1-uid")) folder1 := "rule-1-folder-uid" folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1) - ruleSilence2 := testSilence{ID: "rule-2", RuleUID: utils.Pointer("rule-2-uid")} + ruleSilence2 := testSilence("rule-2", utils.Pointer("rule-2-uid")) folder2 := "rule-2-folder-uid" folder2Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2) - notFoundRule := testSilence{ID: "unknown-rule", RuleUID: utils.Pointer("unknown-rule-uid")} + notFoundRule := testSilence("unknown-rule", utils.Pointer("unknown-rule-uid")) - silences := []testSilence{ + silences := []*models.Silence{ global, ruleSilence1, ruleSilence2, @@ -353,7 +354,7 @@ func TestAuthorizeUpdateSilence(t *testing.T) { user identity.Requester expectedErr error expectedDbAccess bool - overrides map[testSilence]override + overrides map[*models.Silence]override }{ { name: "not authorized without permissions", @@ -388,7 +389,7 @@ func TestAuthorizeUpdateSilence(t *testing.T) { { name: "instance read + silence write", user: newUser(ac.Permission{Action: silenceWrite, Scope: folder1Scope}, ac.Permission{Action: instancesRead}), - overrides: map[testSilence]override{ + overrides: map[*models.Silence]override{ global: { expectedErr: ErrAuthorizationBase, expectedDbAccess: false, @@ -404,7 +405,7 @@ func TestAuthorizeUpdateSilence(t *testing.T) { { name: "silence read + instance write", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesWrite}), - overrides: map[testSilence]override{ + overrides: map[*models.Silence]override{ global: { expectedErr: nil, expectedDbAccess: false, @@ -420,7 +421,7 @@ func TestAuthorizeUpdateSilence(t *testing.T) { { name: "silence read + write", user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceWrite, Scope: folder1Scope}), - overrides: map[testSilence]override{ + overrides: map[*models.Silence]override{ global: { expectedErr: ErrAuthorizationBase, expectedDbAccess: false, @@ -444,12 +445,12 @@ func TestAuthorizeUpdateSilence(t *testing.T) { expectedErr = s.expectedErr expectedDbAccess = s.expectedDbAccess } - t.Run(silence.ID, func(t *testing.T) { + t.Run(*silence.ID, func(t *testing.T) { ac := &recordingAccessControlFake{} store := &fakeRuleUIDToNamespaceStore{ Response: map[string]string{ - *ruleSilence1.RuleUID: folder1, - *ruleSilence2.RuleUID: folder2, + *ruleSilence1.GetRuleUID(): folder1, + *ruleSilence2.GetRuleUID(): folder2, }, } svc := NewSilenceService(ac, store) @@ -472,13 +473,8 @@ func TestAuthorizeUpdateSilence(t *testing.T) { } } -type testSilence struct { - ID string - RuleUID *string -} - -func (t testSilence) GetRuleUID() *string { - return t.RuleUID +func testSilence(id string, ruleUID *string) *models.Silence { + return &models.Silence{ID: &id, RuleUID: ruleUID} } type fakeRuleUIDToNamespaceStore struct { diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 067077326cd..4e9debc356a 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -9,15 +9,12 @@ import ( "strings" "time" - "github.com/go-openapi/strfmt" - alertingNotify "github.com/grafana/alerting/notify" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" 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/store" @@ -59,50 +56,6 @@ func (srv AlertmanagerSrv) RouteGetAMStatus(c *contextmodel.ReqContext) response return response.JSON(http.StatusOK, status) } -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 - if postableSilence.ID == "" { - action = accesscontrol.ActionAlertingInstanceCreate - } - 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) - 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.JSON(http.StatusAccepted, apimodels.PostSilencesOKBody{ - SilenceID: silenceID, - }) -} - func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *contextmodel.ReqContext) response.Response { am, errResp := srv.AlertmanagerFor(c.SignedInUser.GetOrgID()) if errResp != nil { @@ -117,22 +70,6 @@ func (srv AlertmanagerSrv) RouteDeleteAlertingConfig(c *contextmodel.ReqContext) return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration deleted; the default is applied"}) } -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, "") - } - return response.JSON(http.StatusOK, util.DynMap{"message": "silence deleted"}) -} - func (srv AlertmanagerSrv) RouteGetAlertingConfig(c *contextmodel.ReqContext) response.Response { canSeeAutogen := c.SignedInUser.HasRole(org.RoleAdmin) config, err := srv.mam.GetAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID(), canSeeAutogen) @@ -208,40 +145,6 @@ func (srv AlertmanagerSrv) RouteGetAMAlerts(c *contextmodel.ReqContext) response return response.JSON(http.StatusOK, alerts) } -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) - 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.JSON(http.StatusOK, gettableSilence) -} - -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")) - 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.JSON(http.StatusOK, gettableSilences) -} - func (srv AlertmanagerSrv) RoutePostGrafanaAlertingConfigHistoryActivate(c *contextmodel.ReqContext, id string) response.Response { confId, err := strconv.ParseInt(id, 10, 64) if err != nil { diff --git a/pkg/services/ngalert/api/api_alertmanager_silences.go b/pkg/services/ngalert/api/api_alertmanager_silences.go new file mode 100644 index 00000000000..6226e73a528 --- /dev/null +++ b/pkg/services/ngalert/api/api_alertmanager_silences.go @@ -0,0 +1,112 @@ +package api + +import ( + "errors" + "fmt" + "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" + 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/util" +) + +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) + 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.JSON(http.StatusOK, gettableSilence) +} + +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")) + 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.JSON(http.StatusOK, gettableSilences) +} + +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 + if postableSilence.ID == "" { + action = accesscontrol.ActionAlertingInstanceCreate + } + 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) + 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.JSON(http.StatusAccepted, apimodels.PostSilencesOKBody{ + SilenceID: silenceID, + }) +} + +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, "") + } + 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 new file mode 100644 index 00000000000..a2279dd0603 --- /dev/null +++ b/pkg/services/ngalert/api/api_alertmanager_silences_test.go @@ -0,0 +1,194 @@ +package api + +import ( + "context" + "math/rand" + "net/http" + "testing" + "time" + + "github.com/go-openapi/strfmt" + amv2 "github.com/prometheus/alertmanager/api/v2/models" + "github.com/stretchr/testify/require" + + "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" + "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" +) + +func TestSilenceCreate(t *testing.T) { + makeSilence := func(comment string, createdBy string, + startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) amv2.Silence { + return amv2.Silence{ + Comment: &comment, + CreatedBy: &createdBy, + StartsAt: &startsAt, + EndsAt: &endsAt, + Matchers: matchers, + } + } + + now := time.Now() + dt := func(t time.Time) strfmt.DateTime { return strfmt.DateTime(t) } + tru := true + testString := "testName" + matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &tru, IsRegex: &tru, Value: &testString}} + + cases := []struct { + name string + silence amv2.Silence + status int + }{ + {"Valid Silence", + makeSilence("", "tests", dt(now), dt(now.Add(1*time.Second)), matchers), + http.StatusAccepted, + }, + {"No Comment Silence", + func() amv2.Silence { + s := makeSilence("", "tests", dt(now), dt(now.Add(1*time.Second)), matchers) + s.Comment = nil + return s + }(), + http.StatusBadRequest, + }, + } + + for _, cas := range cases { + t.Run(cas.name, func(t *testing.T) { + rc := contextmodel.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &user.SignedInUser{ + OrgRole: org.RoleEditor, + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, + }, + }, + } + + srv := createSut(t) + + resp := srv.RouteCreateSilence(&rc, amv2.PostableSilence{ + ID: "", + Silence: cas.silence, + }) + require.Equal(t, cas.status, resp.Status()) + }) + } +} + +func TestRouteCreateSilence(t *testing.T) { + tesCases := []struct { + name string + silence func() apimodels.PostableSilence + permissions map[int64]map[string][]string + expectedStatus int + }{ + { + name: "new silence, role-based access control is enabled, not authorized", + silence: silenceGen(withEmptyID), + permissions: map[int64]map[string][]string{ + 1: {}, + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "new silence, role-based access control is enabled, authorized", + silence: silenceGen(withEmptyID), + permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, + }, + expectedStatus: http.StatusAccepted, + }, + { + name: "update silence, role-based access control is enabled, not authorized", + silence: silenceGen(), + permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, + }, + expectedStatus: http.StatusForbidden, + }, + { + name: "update silence, role-based access control is enabled, authorized", + silence: silenceGen(), + permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionAlertingInstanceUpdate: {}}, + }, + expectedStatus: http.StatusAccepted, + }, + } + + for _, tesCase := range tesCases { + t.Run(tesCase.name, func(t *testing.T) { + sut := createSut(t) + + rc := contextmodel.ReqContext{ + Context: &web.Context{ + Req: &http.Request{}, + }, + SignedInUser: &user.SignedInUser{ + Permissions: tesCase.permissions, + OrgID: 1, + }, + } + + silence := tesCase.silence() + + if silence.ID != "" { + alertmanagerFor, err := sut.mam.AlertmanagerFor(1) + require.NoError(t, err) + silence.ID = "" + newID, err := alertmanagerFor.CreateSilence(context.Background(), &silence) + require.NoError(t, err) + silence.ID = newID + } + + 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 da6c5c92d11..a9b04aa3934 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -4,23 +4,20 @@ import ( "context" "crypto/md5" "encoding/json" - "math/rand" "net/http" "testing" "time" - "github.com/go-openapi/strfmt" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - alertingNotify "github.com/grafana/alerting/notify" - amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + alertingNotify "github.com/grafana/alerting/notify" + "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -35,7 +32,6 @@ import ( secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -624,141 +620,6 @@ func TestRoutePostTestTemplates(t *testing.T) { }) } -func TestSilenceCreate(t *testing.T) { - makeSilence := func(comment string, createdBy string, - startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) amv2.Silence { - return amv2.Silence{ - Comment: &comment, - CreatedBy: &createdBy, - StartsAt: &startsAt, - EndsAt: &endsAt, - Matchers: matchers, - } - } - - now := time.Now() - dt := func(t time.Time) strfmt.DateTime { return strfmt.DateTime(t) } - tru := true - testString := "testName" - matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &tru, IsRegex: &tru, Value: &testString}} - - cases := []struct { - name string - silence amv2.Silence - status int - }{ - {"Valid Silence", - makeSilence("", "tests", dt(now), dt(now.Add(1*time.Second)), matchers), - http.StatusAccepted, - }, - {"No Comment Silence", - func() amv2.Silence { - s := makeSilence("", "tests", dt(now), dt(now.Add(1*time.Second)), matchers) - s.Comment = nil - return s - }(), - http.StatusBadRequest, - }, - } - - for _, cas := range cases { - t.Run(cas.name, func(t *testing.T) { - rc := contextmodel.ReqContext{ - Context: &web.Context{ - Req: &http.Request{}, - }, - SignedInUser: &user.SignedInUser{ - OrgRole: org.RoleEditor, - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, - }, - }, - } - - srv := createSut(t) - - resp := srv.RouteCreateSilence(&rc, amv2.PostableSilence{ - ID: "", - Silence: cas.silence, - }) - require.Equal(t, cas.status, resp.Status()) - }) - } -} - -func TestRouteCreateSilence(t *testing.T) { - tesCases := []struct { - name string - silence func() apimodels.PostableSilence - permissions map[int64]map[string][]string - expectedStatus int - }{ - { - name: "new silence, role-based access control is enabled, not authorized", - silence: silenceGen(withEmptyID), - permissions: map[int64]map[string][]string{ - 1: {}, - }, - expectedStatus: http.StatusForbidden, - }, - { - name: "new silence, role-based access control is enabled, authorized", - silence: silenceGen(withEmptyID), - permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, - }, - expectedStatus: http.StatusAccepted, - }, - { - name: "update silence, role-based access control is enabled, not authorized", - silence: silenceGen(), - permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingInstanceCreate: {}}, - }, - expectedStatus: http.StatusForbidden, - }, - { - name: "update silence, role-based access control is enabled, authorized", - silence: silenceGen(), - permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingInstanceUpdate: {}}, - }, - expectedStatus: http.StatusAccepted, - }, - } - - for _, tesCase := range tesCases { - t.Run(tesCase.name, func(t *testing.T) { - sut := createSut(t) - - rc := contextmodel.ReqContext{ - Context: &web.Context{ - Req: &http.Request{}, - }, - SignedInUser: &user.SignedInUser{ - Permissions: tesCase.permissions, - OrgID: 1, - }, - } - - silence := tesCase.silence() - - if silence.ID != "" { - alertmanagerFor, err := sut.mam.AlertmanagerFor(1) - require.NoError(t, err) - silence.ID = "" - newID, err := alertmanagerFor.CreateSilence(context.Background(), &silence) - require.NoError(t, err) - silence.ID = newID - } - - response := sut.RouteCreateSilence(&rc, silence) - require.Equal(t, tesCase.expectedStatus, response.Status()) - }) - } -} - func createSut(t *testing.T) AlertmanagerSrv { t.Helper() @@ -973,44 +834,6 @@ var brokenConfig = ` } }` -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 = "" -} - func createRequestCtxInOrg(org int64) *contextmodel.ReqContext { return &contextmodel.ReqContext{ Context: &web.Context{ diff --git a/pkg/services/ngalert/models/silence.go b/pkg/services/ngalert/models/silence.go new file mode 100644 index 00000000000..dbf9a0946de --- /dev/null +++ b/pkg/services/ngalert/models/silence.go @@ -0,0 +1,11 @@ +package models + +// Silence is the model-layer representation of an alertmanager silence. +type Silence struct { // TODO implement using matchers + ID *string + RuleUID *string +} + +func (s *Silence) GetRuleUID() *string { + return s.RuleUID +}