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:
Matthew Jacobson 2024-05-03 15:32:30 -04:00 committed by GitHub
parent e9b932c8f6
commit babfa2beac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1192 additions and 230 deletions

View File

@ -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 {

View File

@ -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(

View File

@ -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 {

View File

@ -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"})
}

View File

@ -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 = ""
}

View File

@ -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),
}
}

View File

@ -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":

View 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,
}
}

View File

@ -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
}

View File

@ -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)
}

View 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")
})
}
}

View File

@ -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("")
}
}

View File

@ -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,
}
}

View 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
}

View File

@ -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)

View File

@ -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)

View 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
}

View File

@ -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,
},
}
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View 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")
})
})
}
}

View File

@ -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)
}
})
}

View File

@ -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 {