mirror of
https://github.com/grafana/grafana.git
synced 2025-02-10 07:35:45 -06:00
Alerting: Hook up GMA silence APIs to new authentication handler (#86625)
This PR connects the new RBAC authentication service to existing alertmanager API silence endpoints.
This commit is contained in:
parent
e9b932c8f6
commit
babfa2beac
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -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 {
|
||||
|
@ -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"})
|
||||
}
|
||||
|
@ -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 = ""
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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":
|
||||
|
30
pkg/services/ngalert/api/compat_silences.go
Normal file
30
pkg/services/ngalert/api/compat_silences.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
50
pkg/services/ngalert/models/silence_test.go
Normal file
50
pkg/services/ngalert/models/silence_test.go
Normal file
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
@ -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("")
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
9
pkg/services/ngalert/notifier/errors.go
Normal file
9
pkg/services/ngalert/notifier/errors.go
Normal file
@ -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
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
127
pkg/services/ngalert/notifier/silence_svc.go
Normal file
127
pkg/services/ngalert/notifier/silence_svc.go
Normal file
@ -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
|
||||
}
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
422
pkg/tests/api/alerting/api_alertmanager_silence_test.go
Normal file
422
pkg/tests/api/alerting/api_alertmanager_silence_test.go
Normal file
@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user