Alerting: Add optional metadata via query param to silence GET requests (#88000)

* Alerting: Add optional metadata to GET silence responses

- ruleMetadata: to request rule metadata.
- accesscontrol: to request access control metadata.
This commit is contained in:
Matthew Jacobson 2024-05-30 12:04:47 -04:00 committed by GitHub
parent 413013a000
commit 09cb3a6048
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1015 additions and 32 deletions

View File

@ -22,6 +22,7 @@ type FakeRuleService struct {
AuthorizeDatasourceAccessForRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) error
HasAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) (bool, error)
AuthorizeAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) error
HasAccessInFolderFunc func(context.Context, identity.Requester, accesscontrol.Namespaced) (bool, error)
AuthorizeAccessInFolderFunc func(context.Context, identity.Requester, accesscontrol.Namespaced) error
AuthorizeRuleChangesFunc func(context.Context, identity.Requester, *store.GroupDelta) error
@ -76,6 +77,14 @@ func (s *FakeRuleService) AuthorizeAccessToRuleGroup(ctx context.Context, user i
return nil
}
func (s *FakeRuleService) HasAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) (bool, error) {
s.Calls = append(s.Calls, Call{"HasAccessInFolder", []interface{}{ctx, user, namespaced}})
if s.HasAccessInFolderFunc != nil {
return s.HasAccessInFolderFunc(ctx, user, namespaced)
}
return false, nil
}
func (s *FakeRuleService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error {
s.Calls = append(s.Calls, Call{"AuthorizeAccessInFolder", []interface{}{ctx, user, namespaced}})
if s.AuthorizeAccessInFolderFunc != nil {

View File

@ -0,0 +1,58 @@
package fakes
import (
"context"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
type FakeSilenceService struct {
FilterByAccessFunc func(ctx context.Context, user identity.Requester, silences ...*models.Silence) ([]*models.Silence, error)
AuthorizeReadSilenceFunc func(ctx context.Context, user identity.Requester, silence *models.Silence) error
AuthorizeCreateSilenceFunc func(ctx context.Context, user identity.Requester, silence *models.Silence) error
AuthorizeUpdateSilenceFunc func(ctx context.Context, user identity.Requester, silence *models.Silence) error
SilenceAccessFunc func(ctx context.Context, user identity.Requester, silences []*models.Silence) (map[*models.Silence]models.SilencePermissionSet, error)
Calls []Call
}
func (s *FakeSilenceService) FilterByAccess(ctx context.Context, user identity.Requester, silences ...*models.Silence) ([]*models.Silence, error) {
s.Calls = append(s.Calls, Call{"FilterByAccess", []interface{}{ctx, user, silences}})
if s.FilterByAccessFunc != nil {
return s.FilterByAccessFunc(ctx, user, silences...)
}
return nil, nil
}
func (s *FakeSilenceService) AuthorizeReadSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error {
s.Calls = append(s.Calls, Call{"AuthorizeReadSilence", []interface{}{ctx, user, silence}})
if s.AuthorizeReadSilenceFunc != nil {
return s.AuthorizeReadSilenceFunc(ctx, user, silence)
}
return nil
}
func (s *FakeSilenceService) AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error {
s.Calls = append(s.Calls, Call{"AuthorizeCreateSilence", []interface{}{ctx, user, silence}})
if s.AuthorizeCreateSilenceFunc != nil {
return s.AuthorizeCreateSilenceFunc(ctx, user, silence)
}
return nil
}
func (s *FakeSilenceService) AuthorizeUpdateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error {
s.Calls = append(s.Calls, Call{"AuthorizeUpdateSilence", []interface{}{ctx, user, silence}})
if s.AuthorizeUpdateSilenceFunc != nil {
return s.AuthorizeUpdateSilenceFunc(ctx, user, silence)
}
return nil
}
func (s *FakeSilenceService) SilenceAccess(ctx context.Context, user identity.Requester, silences []*models.Silence) (map[*models.Silence]models.SilencePermissionSet, error) {
s.Calls = append(s.Calls, Call{"SilenceAccess", []interface{}{ctx, user, silences}})
if s.SilenceAccessFunc != nil {
return s.SilenceAccessFunc(ctx, user, silences)
}
return nil, nil
}

View File

@ -123,15 +123,15 @@ func (s SilenceService) FilterByAccess(ctx context.Context, user identity.Reques
}
result := make([]*models.Silence, 0, len(silences))
namespacesByAccess := make(map[string]bool) // caches results of permissions check for each namespace to avoid repeated checks for the same folder
accessCacheByFolder := make(map[string]bool)
for _, silWithFolder := range silencesWithFolders {
hasAccess, ok := namespacesByAccess[silWithFolder.folderUID]
hasAccess, ok := accessCacheByFolder[silWithFolder.folderUID]
if !ok {
hasAccess = s.authorizeReadSilence(ctx, user, silWithFolder) == nil
// Cache non-empty namespaces to avoid repeated checks for the same folder.
if silWithFolder.folderUID != "" {
namespacesByAccess[silWithFolder.folderUID] = hasAccess
accessCacheByFolder[silWithFolder.folderUID] = hasAccess
}
}
if hasAccess {
@ -284,6 +284,66 @@ func (s SilenceService) authorizeUpdateSilence(ctx context.Context, user identit
})
}
// SilenceAccess returns the permission sets for a slice of silences. The permission set includes read, write, and
// create which corresponds the given user being able to read, write, and create each given silence, respectively.
func (s SilenceService) SilenceAccess(ctx context.Context, user identity.Requester, silences []*models.Silence) (map[*models.Silence]models.SilencePermissionSet, error) {
basePerms := make(models.SilencePermissionSet, 3)
canReadAll, err := s.authorizeReadSilencePreConditions(ctx, user)
if err != nil || canReadAll {
basePerms[models.SilencePermissionRead] = err == nil
}
canUpdateAny, err := s.authorizeUpdateSilencePreConditions(ctx, user)
if err != nil || canUpdateAny {
basePerms[models.SilencePermissionWrite] = err == nil
}
canCreateAny, err := s.authorizeCreateSilencePreConditions(ctx, user)
if err != nil || canCreateAny {
basePerms[models.SilencePermissionCreate] = err == nil
}
if basePerms.AllSet() {
// Shortcut for the case when all permissions are known based on preconditions. We don't need to hit the database to find folder UIDs.
return withPermissionSet(silences, basePerms), nil
}
silencesWithFolders, err := s.withFolders(ctx, user.GetOrgID(), silences...)
if err != nil {
return nil, err
}
result := make(map[*models.Silence]models.SilencePermissionSet, len(silences))
accessCacheByFolder := make(map[string]models.SilencePermissionSet)
for _, silWithFolder := range silencesWithFolders {
if perms, ok := accessCacheByFolder[silWithFolder.folderUID]; ok {
result[silWithFolder.Silence] = perms.Clone()
continue
}
permSet := basePerms.Clone()
if _, ok := permSet[models.SilencePermissionRead]; !ok {
err := s.authorizeReadSilence(ctx, user, silWithFolder)
permSet[models.SilencePermissionRead] = err == nil
}
if _, ok := permSet[models.SilencePermissionWrite]; !ok {
err := s.authorizeUpdateSilence(ctx, user, silWithFolder)
permSet[models.SilencePermissionWrite] = err == nil
}
if _, ok := permSet[models.SilencePermissionCreate]; !ok {
err := s.authorizeCreateSilence(ctx, user, silWithFolder)
permSet[models.SilencePermissionCreate] = err == nil
}
result[silWithFolder.Silence] = permSet
// Cache non-empty namespaces to avoid repeated checks for the same folder.
if silWithFolder.folderUID != "" {
accessCacheByFolder[silWithFolder.folderUID] = permSet
}
}
return result, nil
}
// withFolders resolves rule UIDs to folder UIDs for rule-specific silences and returns a list of silenceWithFolder
// that includes rule information, if available.
func (s SilenceService) withFolders(ctx context.Context, orgID int64, silences ...*models.Silence) ([]*silenceWithFolder, error) {
@ -313,3 +373,11 @@ func (s SilenceService) withFolders(ctx context.Context, orgID int64, silences .
}
return result, nil
}
func withPermissionSet(silences []*models.Silence, perms models.SilencePermissionSet) map[*models.Silence]models.SilencePermissionSet {
result := make(map[*models.Silence]models.SilencePermissionSet, len(silences))
for _, silence := range silences {
result[silence] = perms.Clone()
}
return result
}

View File

@ -212,6 +212,12 @@ func TestAuthorizeReadSilence(t *testing.T) {
} else {
require.Equal(t, 0, store.Calls)
}
// Verify SilenceAccess.
permSets, err := svc.SilenceAccess(context.Background(), testCase.user, []*models.Silence{silence})
assert.NoError(t, err)
assert.Len(t, permSets, 1)
assert.Equal(t, testCase.expectedErr == nil, permSets[silence].Has(models.SilencePermissionRead))
})
}
})
@ -383,6 +389,12 @@ func TestAuthorizeCreateSilence(t *testing.T) {
} else {
require.Equal(t, 0, store.Calls)
}
// Verify SilenceAccess.
permSets, err := svc.SilenceAccess(context.Background(), testCase.user, []*models.Silence{silence})
assert.NoError(t, err)
assert.Len(t, permSets, 1)
assert.Equal(t, expectedErr == nil, permSets[silence].Has(models.SilencePermissionCreate))
})
}
})
@ -554,12 +566,275 @@ func TestAuthorizeUpdateSilence(t *testing.T) {
} else {
require.Equal(t, 0, store.Calls)
}
// Verify SilenceAccess.
permSets, err := svc.SilenceAccess(context.Background(), testCase.user, []*models.Silence{silence})
assert.NoError(t, err)
assert.Len(t, permSets, 1)
assert.Equal(t, expectedErr == nil, permSets[silence].Has(models.SilencePermissionWrite))
})
}
})
}
}
func TestSilenceAccess(t *testing.T) {
global := testSilence("global", nil)
ruleSilence1 := testSilence("rule-1", utils.Pointer("rule-1-uid"))
folder1 := "rule-1-folder-uid"
folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1)
ruleSilence2 := testSilence("rule-2", utils.Pointer("rule-2-uid"))
folder2 := "rule-2-folder-uid"
folder2Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2)
notFoundRule := testSilence("unknown-rule", utils.Pointer("unknown-rule-uid"))
silences := []*models.Silence{
global,
ruleSilence1,
ruleSilence2,
notFoundRule,
}
type override struct {
expectedPermissions models.SilencePermissionSet
}
permit := func(permission ...models.SilencePermission) override {
o := override{expectedPermissions: models.SilencePermissionSet{}}
for _, p := range permission {
o.expectedPermissions[p] = true
}
return o
}
deny := func(permission ...models.SilencePermission) override {
o := override{expectedPermissions: models.SilencePermissionSet{}}
for _, p := range permission {
o.expectedPermissions[p] = false
}
return o
}
testCases := []struct {
name string
user identity.Requester
expectedPermissions models.SilencePermissionSet
expectedDbAccess bool
overrides map[*models.Silence]override
}{
{
name: "not authorized without permissions",
user: newUser(),
},
{
name: "instance read gives read access to everything",
user: newUser(ac.Permission{Action: instancesRead}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
},
},
{
name: "silence wildcard read gives read access to everything",
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
},
},
{
name: "silence read in folders gives read access to global and folders",
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceRead, Scope: folder2Scope}),
overrides: map[*models.Silence]override{
global: permit(models.SilencePermissionRead),
ruleSilence1: permit(models.SilencePermissionRead),
ruleSilence2: permit(models.SilencePermissionRead),
},
expectedPermissions: models.SilencePermissionSet{},
expectedDbAccess: true,
},
{
name: "instance reade+write+create can do everything",
user: newUser(ac.Permission{Action: instancesRead}, ac.Permission{Action: instancesWrite}, ac.Permission{Action: instancesCreate}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionCreate: true,
models.SilencePermissionWrite: true,
},
},
{
name: "silence wildcard read + instance write+create can do everything",
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: instancesWrite}, ac.Permission{Action: instancesCreate}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionCreate: true,
models.SilencePermissionWrite: true,
},
},
{
name: "instance readr+write can read and write",
user: newUser(ac.Permission{Action: instancesRead}, ac.Permission{Action: instancesWrite}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionWrite: true,
},
},
{
name: "silence wildcard read + instance write can read and write",
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: instancesWrite}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionWrite: true,
},
},
{
name: "instance reader+create can read and create",
user: newUser(ac.Permission{Action: instancesRead}, ac.Permission{Action: instancesCreate}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionCreate: true,
},
},
{
name: "silence wildcard read + instance create can read and create",
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: instancesCreate}),
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionCreate: true,
},
},
{
name: "cannot write/create without read - instance permissions",
user: newUser(ac.Permission{Action: instancesWrite}, ac.Permission{Action: instancesCreate}),
expectedPermissions: models.SilencePermissionSet{},
},
{
name: "cannot write/create without read - silence permissions",
user: newUser(ac.Permission{Action: silenceWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: silenceCreate, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
expectedPermissions: models.SilencePermissionSet{},
},
{
name: "instance read + silence write in folder",
user: newUser(ac.Permission{Action: silenceWrite, Scope: folder1Scope}, ac.Permission{Action: instancesRead}),
overrides: map[*models.Silence]override{
ruleSilence1: permit(models.SilencePermissionWrite),
},
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
},
expectedDbAccess: true,
},
{
name: "instance read + silence create in folder",
user: newUser(ac.Permission{Action: silenceCreate, Scope: folder1Scope}, ac.Permission{Action: instancesRead}),
overrides: map[*models.Silence]override{
ruleSilence1: permit(models.SilencePermissionCreate),
},
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
},
expectedDbAccess: true,
},
{
name: "silence read in folder + instance write also provides global write",
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesWrite}),
overrides: map[*models.Silence]override{
global: permit(models.SilencePermissionRead, models.SilencePermissionWrite),
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionWrite),
},
expectedPermissions: models.SilencePermissionSet{},
expectedDbAccess: true,
},
{
name: "silence read in folder + instance create also provides global create",
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesCreate}),
overrides: map[*models.Silence]override{
global: permit(models.SilencePermissionRead, models.SilencePermissionCreate),
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionCreate),
},
expectedPermissions: models.SilencePermissionSet{},
expectedDbAccess: true,
},
{
name: "silence wildcard write doesn't provide global write but does provide unknown rule write",
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: silenceWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
overrides: map[*models.Silence]override{
global: deny(models.SilencePermissionWrite),
notFoundRule: deny(models.SilencePermissionWrite), // This is arguable, can consider changing this in the future.
},
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionWrite: true,
},
expectedDbAccess: true,
},
{
name: "silence wildcard create doesn't provide global create but does provide unknown rule create",
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: silenceCreate, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
overrides: map[*models.Silence]override{
global: deny(models.SilencePermissionCreate),
notFoundRule: deny(models.SilencePermissionCreate), // This is arguable, can consider changing this in the future.
},
expectedPermissions: models.SilencePermissionSet{
models.SilencePermissionRead: true,
models.SilencePermissionCreate: true,
},
expectedDbAccess: true,
},
{
name: "silence read + write in single folder",
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceWrite, Scope: folder1Scope}),
overrides: map[*models.Silence]override{
global: permit(models.SilencePermissionRead),
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionWrite),
},
expectedPermissions: models.SilencePermissionSet{},
expectedDbAccess: true,
},
{
name: "silence read + create in single folder",
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceCreate, Scope: folder1Scope}),
overrides: map[*models.Silence]override{
global: permit(models.SilencePermissionRead),
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionCreate),
},
expectedPermissions: models.SilencePermissionSet{},
expectedDbAccess: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ac := &recordingAccessControlFake{}
store := &fakeRuleUIDToNamespaceStore{
Response: map[string]string{
*ruleSilence1.GetRuleUID(): folder1,
*ruleSilence2.GetRuleUID(): folder2,
},
}
svc := NewSilenceService(ac, store)
perms, err := svc.SilenceAccess(context.Background(), tc.user, silences)
assert.NoError(t, err)
if tc.expectedDbAccess {
assert.Equalf(t, 1, store.Calls, "expected 1 db access, but got %d store calls", store.Calls)
} else {
assert.Equalf(t, 0, store.Calls, "expected no db access, but got %d store calls", store.Calls)
}
for _, silence := range silences {
expectedPermissions := tc.expectedPermissions.Clone()
if s, ok := tc.overrides[silence]; ok {
for k, v := range s.expectedPermissions {
expectedPermissions[k] = v
}
}
for _, permission := range models.SilencePermissions() {
assert.Equalf(t, expectedPermissions.Has(permission), perms[silence].Has(permission), "expected %s=%t permission for silence %s but got %t", permission, expectedPermissions.Has(permission), *silence.ID, perms[silence].Has(permission))
}
}
})
}
}
func testSilence(id string, ruleUID *string) *models.Silence {
s := &models.Silence{ID: &id}
if ruleUID != nil {

View File

@ -95,11 +95,18 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
api.DatasourceCache,
NewLotexAM(proxy, logger),
&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),
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,
api.RuleStore,
ruleAuthzService,
),
},
), m)
// Register endpoints for proxying to Prometheus-compatible backends.

View File

@ -21,6 +21,8 @@ type SilenceService interface {
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
WithAccessControlMetadata(ctx context.Context, user identity.Requester, silencesWithMetadata ...*models.SilenceWithMetadata) error
WithRuleMetadata(ctx context.Context, user identity.Requester, silences ...*models.SilenceWithMetadata) error
}
// RouteGetSilence is the single silence GET endpoint for Grafana AM.
@ -29,7 +31,22 @@ func (srv AlertmanagerSrv) RouteGetSilence(c *contextmodel.ReqContext, silenceID
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get silence", err)
}
return response.JSON(http.StatusOK, SilenceToGettableSilence(*silence))
silenceWithMetadata := &models.SilenceWithMetadata{
Silence: silence,
}
if c.QueryBool("accesscontrol") {
if err := srv.silenceSvc.WithAccessControlMetadata(c.Req.Context(), c.SignedInUser, silenceWithMetadata); err != nil {
srv.log.Error("failed to get silence access control metadata", "silenceID", silenceID, "error", err)
}
}
if c.QueryBool("ruleMetadata") {
if err := srv.silenceSvc.WithRuleMetadata(c.Req.Context(), c.SignedInUser, silenceWithMetadata); err != nil {
srv.log.Error("failed to get silence rule metadata", "silenceID", silenceID, "error", err)
}
}
return response.JSON(http.StatusOK, SilenceToGettableGrafanaSilence(silenceWithMetadata))
}
// RouteGetSilences is the silence list GET endpoint for Grafana AM.
@ -38,7 +55,19 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *contextmodel.ReqContext) response
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to list silence", err)
}
return response.JSON(http.StatusOK, SilencesToGettableSilences(silences))
silencesWithMetadata := withEmptyMetadata(silences...)
if c.QueryBool("accesscontrol") {
if err := srv.silenceSvc.WithAccessControlMetadata(c.Req.Context(), c.SignedInUser, silencesWithMetadata...); err != nil {
srv.log.Error("failed to get silence access control metadata", "error", err)
}
}
if c.QueryBool("ruleMetadata") {
if err := srv.silenceSvc.WithRuleMetadata(c.Req.Context(), c.SignedInUser, silencesWithMetadata...); err != nil {
srv.log.Error("failed to get silence rule metadata", "error", err)
}
}
return response.JSON(http.StatusOK, SilencesToGettableGrafanaSilences(silencesWithMetadata))
}
// RouteCreateSilence is the silence POST (create + update) endpoint for Grafana AM.
@ -69,3 +98,15 @@ func (srv AlertmanagerSrv) RouteDeleteSilence(c *contextmodel.ReqContext, silenc
}
return response.JSON(http.StatusOK, util.DynMap{"message": "silence deleted"})
}
// withEmptyMetadata creates a slice of SilenceWithMetadata from a slice of Silence where the metadata for each silence
// is empty.
func withEmptyMetadata(silences ...*models.Silence) []*models.SilenceWithMetadata {
silencesWithMetadata := make([]*models.SilenceWithMetadata, 0, len(silences))
for _, silence := range silences {
silencesWithMetadata = append(silencesWithMetadata, &models.SilenceWithMetadata{
Silence: silence,
})
}
return silencesWithMetadata
}

View File

@ -633,12 +633,13 @@ func createSut(t *testing.T) AlertmanagerSrv {
log := log.NewNopLogger()
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
ruleStore := ngfakes.NewRuleStore(t)
ruleAuthzService := accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures()))
return AlertmanagerSrv{
mam: mam,
crypto: mam.Crypto,
ac: ac,
log: log,
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(ac, ruleStore), ruleStore, log, mam),
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(ac, ruleStore), ruleStore, log, mam, ruleStore, ruleAuthzService),
}
}

View File

@ -1,20 +1,46 @@
package api
import (
"fmt"
"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 SilenceToGettableGrafanaSilence(s *models.SilenceWithMetadata) definitions.GettableGrafanaSilence {
gettable := definitions.GettableGrafanaSilence{
GettableSilence: (*definitions.GettableSilence)(s.Silence),
}
if s.Metadata.Permissions != nil {
gettable.Permissions = make(map[definitions.SilencePermission]bool, len(*s.Metadata.Permissions))
for _, permission := range models.SilencePermissions() {
p, err := SilencePermissionToAPI(permission)
if err != nil {
// Skip unknown permissions in response.
continue
}
gettable.Permissions[p] = s.Metadata.Permissions.Has(permission)
}
}
if s.Metadata.RuleMetadata != nil {
gettable.Metadata = &definitions.SilenceMetadata{
RuleUID: s.Metadata.RuleMetadata.RuleUID,
RuleTitle: s.Metadata.RuleMetadata.RuleTitle,
FolderUID: s.Metadata.RuleMetadata.FolderUID,
}
}
return gettable
}
func SilencesToGettableSilences(silences []*models.Silence) definitions.GettableSilences {
res := make(definitions.GettableSilences, 0, len(silences))
func SilencesToGettableGrafanaSilences(silences []*models.SilenceWithMetadata) definitions.GettableGrafanaSilences {
res := make(definitions.GettableGrafanaSilences, 0, len(silences))
for _, sil := range silences {
apiSil := SilenceToGettableSilence(*sil)
apiSil := SilenceToGettableGrafanaSilence(sil)
res = append(res, &apiSil)
}
return res
@ -28,3 +54,16 @@ func PostableSilenceToSilence(s definitions.PostableSilence) models.Silence {
Silence: s.Silence,
}
}
func SilencePermissionToAPI(p models.SilencePermission) (definitions.SilencePermission, error) {
switch p {
case models.SilencePermissionRead:
return definitions.SilencePermissionRead, nil
case models.SilencePermissionCreate:
return definitions.SilencePermissionCreate, nil
case models.SilencePermissionWrite:
return definitions.SilencePermissionWrite, nil
default:
return "", fmt.Errorf("unknown permission: %s", p)
}
}

View File

@ -8,12 +8,13 @@ import (
"time"
"github.com/go-openapi/strfmt"
"github.com/grafana/alerting/definition"
"github.com/mohae/deepcopy"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"gopkg.in/yaml.v3"
"github.com/grafana/alerting/definition"
)
// swagger:route POST /alertmanager/grafana/config/api/v1/alerts alertmanager RoutePostGrafanaAlertingConfig
@ -182,7 +183,7 @@ import (
// get silences
//
// Responses:
// 200: gettableSilences
// 200: gettableGrafanaSilences
// 400: ValidationError
// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteGetSilences
@ -216,7 +217,7 @@ import (
// get silence
//
// Responses:
// 200: gettableSilence
// 200: gettableGrafanaSilence
// 400: ValidationError
// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence
@ -389,6 +390,14 @@ type GetDeleteSilenceParams struct {
type GetSilencesParams struct {
// in:query
Filter []string `json:"filter"`
// Return rule metadata with silence.
// in:query
// required:false
RuleMetadata bool `json:"ruleMetadata"`
// Return access control metadata with silence.
// in:query
// required:false
AccessControl bool `json:"accesscontrol"`
}
// swagger:model
@ -479,6 +488,55 @@ type GettableSilences = amv2.GettableSilences
// swagger:model gettableSilence
type GettableSilence = amv2.GettableSilence
// swagger:model gettableGrafanaSilence
type GettableGrafanaSilence struct {
*GettableSilence `json:",inline"`
Metadata *SilenceMetadata `json:"metadata,omitempty"`
// example: {"read": true, "write": false, "create": false}
Permissions map[SilencePermission]bool `json:"accessControl,omitempty"`
}
type SilenceMetadata struct {
RuleUID string `json:"rule_uid,omitempty"`
RuleTitle string `json:"rule_title,omitempty"`
FolderUID string `json:"folder_uid,omitempty"`
}
type SilencePermission string
const (
SilencePermissionRead SilencePermission = "read"
SilencePermissionCreate SilencePermission = "create"
SilencePermissionWrite SilencePermission = "write"
)
// Correctly embed the GettableSilence into the GettableGrafanaSilence struct. This is needed because GettableSilence
// has a custom UnmarshalJSON method.
func (s GettableGrafanaSilence) MarshalJSON() ([]byte, error) {
gettable, err := json.Marshal(s.GettableSilence)
if err != nil {
return nil, err
}
var data map[string]interface{}
if err := json.Unmarshal(gettable, &data); err != nil {
return nil, err
}
if s.Metadata != nil {
data["metadata"] = s.Metadata
}
if s.Permissions != nil {
data["accessControl"] = s.Permissions
}
return json.Marshal(data)
}
// swagger:model gettableGrafanaSilences
type GettableGrafanaSilences []*GettableGrafanaSilence
// swagger:model gettableAlerts
type GettableAlerts = amv2.GettableAlerts

View File

@ -14,6 +14,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/grafana/grafana-plugin-sdk-go/data"
alertingModels "github.com/grafana/alerting/models"
@ -609,6 +610,7 @@ type GetAlertRulesGroupByRuleUIDQuery struct {
// ListAlertRulesQuery is the query for listing alert rules
type ListAlertRulesQuery struct {
OrgID int64
RuleUIDs []string
NamespaceUIDs []string
ExcludeOrgs []int64
RuleGroups []string

View File

@ -2,6 +2,7 @@ package models
import (
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"golang.org/x/exp/maps"
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/alerting/notify"
@ -35,3 +36,63 @@ 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)
}
// SilenceWithMetadata is a helper type for managing a silence with associated metadata.
type SilenceWithMetadata struct {
*Silence
Metadata SilenceMetadata
}
// SilenceMetadata contains metadata about a silence. Fields are pointers to allow for metadata to be optionally loaded.
type SilenceMetadata struct {
RuleMetadata *SilenceRuleMetadata
Permissions *SilencePermissionSet
}
// SilenceRuleMetadata contains metadata about the rule associated with a silence.
type SilenceRuleMetadata struct {
RuleUID string
RuleTitle string
FolderUID string
}
// SilencePermission is a type for representing a silence permission.
type SilencePermission string
const (
SilencePermissionRead SilencePermission = "read"
SilencePermissionCreate SilencePermission = "create"
SilencePermissionWrite SilencePermission = "write"
)
// SilencePermissions returns all possible silence permissions.
func SilencePermissions() [3]SilencePermission {
return [3]SilencePermission{
SilencePermissionRead,
SilencePermissionCreate,
SilencePermissionWrite,
}
}
// SilencePermissionSet represents a set of permissions for a silence.
type SilencePermissionSet map[SilencePermission]bool
// Clone returns a deep copy of the permission set.
func (p SilencePermissionSet) Clone() SilencePermissionSet {
return maps.Clone(p)
}
// AllSet returns true if all possible permissions are set.
func (p SilencePermissionSet) AllSet() bool {
for _, permission := range SilencePermissions() {
if _, ok := p[permission]; !ok {
return false
}
}
return true
}
// Has returns true if the given permission is allowed in the set.
func (p SilencePermissionSet) Has(permission SilencePermission) bool {
return p[permission]
}

View File

@ -48,3 +48,95 @@ func TestSilenceGetRuleUID(t *testing.T) {
})
}
}
func TestSilencePermissionSet(t *testing.T) {
t.Run("Clone", func(t *testing.T) {
perms := SilencePermissionSet{
SilencePermissionRead: true,
SilencePermissionWrite: false,
}
clone := perms.Clone()
assert.Equal(t, perms, clone)
clone[SilencePermissionRead] = false
assert.NotEqual(t, perms, clone)
})
t.Run("AllSet + SilencePermissions", func(t *testing.T) {
readPerms := SilencePermissionSet{
SilencePermissionRead: true,
}
assert.False(t, readPerms.AllSet())
allPerms := SilencePermissionSet{}
for _, perm := range SilencePermissions() {
allPerms[perm] = true
}
assert.True(t, allPerms.AllSet())
})
t.Run("Has", func(t *testing.T) {
testCases := []struct {
name string
permissionSet SilencePermissionSet
expectedHas map[SilencePermission]bool
}{
{
name: "all false",
permissionSet: SilencePermissionSet{
SilencePermissionRead: false,
SilencePermissionWrite: false,
SilencePermissionCreate: false,
},
expectedHas: map[SilencePermission]bool{
SilencePermissionRead: false,
SilencePermissionWrite: false,
SilencePermissionCreate: false,
},
},
{
name: "all true",
permissionSet: SilencePermissionSet{
SilencePermissionRead: true,
SilencePermissionWrite: true,
SilencePermissionCreate: true,
},
expectedHas: map[SilencePermission]bool{
SilencePermissionRead: true,
SilencePermissionWrite: true,
SilencePermissionCreate: true,
},
},
{
name: "mixed",
permissionSet: SilencePermissionSet{
SilencePermissionRead: true,
SilencePermissionWrite: false,
SilencePermissionCreate: true,
},
expectedHas: map[SilencePermission]bool{
SilencePermissionRead: true,
SilencePermissionWrite: false,
SilencePermissionCreate: true,
},
},
{
name: "not set = false",
permissionSet: SilencePermissionSet{
SilencePermissionRead: true,
},
expectedHas: map[SilencePermission]bool{
SilencePermissionRead: true,
SilencePermissionWrite: false,
SilencePermissionCreate: false,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for perm, expected := range tc.expectedHas {
assert.Equal(t, expected, tc.permissionSet.Has(perm))
}
})
}
})
}

View File

@ -3,17 +3,26 @@ package notifier
import (
"context"
"golang.org/x/exp/maps"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"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
authz SilenceAccessControlService
xact transactionManager
log log.Logger
store SilenceStore
ruleStore RuleStore
ruleAuthz RuleAccessControlService
}
type RuleAccessControlService interface {
HasAccessInFolder(ctx context.Context, user identity.Requester, rule accesscontrol.Namespaced) (bool, error)
}
// SilenceAccessControlService provides access control for silences.
@ -22,6 +31,7 @@ type SilenceAccessControlService interface {
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
SilenceAccess(ctx context.Context, user identity.Requester, silences []*models.Silence) (map[*models.Silence]models.SilencePermissionSet, error)
}
// SilenceStore is the interface for storing and retrieving silences. Currently, this is implemented by
@ -34,43 +44,51 @@ type SilenceStore interface {
DeleteSilence(ctx context.Context, orgID int64, id string) error
}
type RuleStore interface {
ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error)
}
func NewSilenceService(
authz SilenceAccessControlService,
xact transactionManager,
log log.Logger,
store SilenceStore,
ruleStore RuleStore,
ruleAuthz RuleAccessControlService,
) *SilenceService {
return &SilenceService{
authz: authz,
xact: xact,
log: log,
store: store,
authz: authz,
xact: xact,
log: log,
store: store,
ruleStore: ruleStore,
ruleAuthz: ruleAuthz,
}
}
// 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)
silence, err := s.store.GetSilence(ctx, user.GetOrgID(), silenceID)
if err != nil {
return nil, err
}
if err := s.authz.AuthorizeReadSilence(ctx, user, gettableSilence); err != nil {
if err := s.authz.AuthorizeReadSilence(ctx, user, silence); err != nil {
return nil, err
}
return gettableSilence, nil
return silence, 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)
silences, err := s.store.ListSilences(ctx, user.GetOrgID(), filter)
if err != nil {
return nil, err
}
return s.authz.FilterByAccess(ctx, user, gettableSilences...)
return s.authz.FilterByAccess(ctx, user, silences...)
}
// CreateSilence creates a new silence.
@ -125,3 +143,86 @@ func (s *SilenceService) DeleteSilence(ctx context.Context, user identity.Reques
return nil
}
// WithAccessControlMetadata adds access control metadata to the given SilenceWithMetadata.
func (s *SilenceService) WithAccessControlMetadata(ctx context.Context, user identity.Requester, silencesWithMetadata ...*models.SilenceWithMetadata) error {
silences := make([]*models.Silence, 0, len(silencesWithMetadata))
for _, silence := range silencesWithMetadata {
silences = append(silences, silence.Silence)
}
permissions, err := s.authz.SilenceAccess(ctx, user, silences)
if err != nil {
return err
}
if len(permissions) != len(silences) {
s.log.Warn("failed to get metadata for all silences")
}
for _, silence := range silencesWithMetadata {
if perms, ok := permissions[silence.Silence]; ok {
silence.Metadata.Permissions = &perms
}
}
return nil
}
// WithRuleMetadata adds rule metadata to the given SilenceWithMetadata.
func (s *SilenceService) WithRuleMetadata(ctx context.Context, user identity.Requester, silences ...*models.SilenceWithMetadata) error {
byRuleUID := make(map[string][]*models.SilenceWithMetadata, len(silences))
for _, silence := range silences {
ruleUID := silence.GetRuleUID()
if ruleUID != nil {
byRuleUID[*ruleUID] = append(byRuleUID[*ruleUID], silence)
silence.Metadata.RuleMetadata = &models.SilenceRuleMetadata{ // Attach metadata with rule UID regardless of access.
RuleUID: *ruleUID,
}
}
}
if len(byRuleUID) == 0 {
return nil
}
q := models.ListAlertRulesQuery{
RuleUIDs: maps.Keys(byRuleUID),
OrgID: user.GetOrgID(),
}
rules, err := s.ruleStore.ListAlertRules(ctx, &q)
if err != nil {
return err
}
accessCacheByFolder := make(map[string]bool)
for _, rule := range rules {
// TODO: Preferably silence service should not need to know about the internal details of rule access control.
// This can be improved by adding a method to ruleAuthz that does the filtering itself or a method that exposes
// an access fingerprint for a rule that callers can use to do their own caching.
fp := rule.NamespaceUID
canAccess, ok := accessCacheByFolder[fp]
if !ok {
var err error
if canAccess, err = s.ruleAuthz.HasAccessInFolder(ctx, user, rule); err != nil {
continue // Assume no access if there is an error but don't cache.
}
accessCacheByFolder[fp] = canAccess // Only cache if there is no error.
}
if !canAccess {
continue
}
if ruleSilences, ok := byRuleUID[rule.UID]; ok {
for _, sil := range ruleSilences {
if sil.Metadata.RuleMetadata == nil {
sil.Metadata.RuleMetadata = &models.SilenceRuleMetadata{}
}
sil.Metadata.RuleMetadata.RuleTitle = rule.Title
sil.Metadata.RuleMetadata.FolderUID = rule.NamespaceUID
}
}
}
return nil
}

View File

@ -0,0 +1,163 @@
package notifier
import (
"context"
"math/rand"
"testing"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
alertingmodels "github.com/grafana/alerting/models"
ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
)
func TestWithAccessControlMetadata(t *testing.T) {
user := ac.BackgroundUser("test", 1, org.RoleNone, nil)
silencesWithMetadata := []*models.SilenceWithMetadata{
{Silence: util.Pointer(models.SilenceGen()())},
{Silence: util.Pointer(models.SilenceGen()())},
{Silence: util.Pointer(models.SilenceGen()())},
}
randPerm := func() models.SilencePermissionSet {
return models.SilencePermissionSet{
models.SilencePermissionRead: rand.Intn(2) == 1,
models.SilencePermissionWrite: rand.Intn(2) == 1,
models.SilencePermissionCreate: rand.Intn(2) == 1,
}
}
t.Run("Attach permissions to silences", func(t *testing.T) {
authz := fakes.FakeSilenceService{}
response := map[*models.Silence]models.SilencePermissionSet{
silencesWithMetadata[0].Silence: randPerm(),
silencesWithMetadata[1].Silence: randPerm(),
silencesWithMetadata[2].Silence: randPerm(),
}
authz.SilenceAccessFunc = func(ctx context.Context, user identity.Requester, silences []*models.Silence) (map[*models.Silence]models.SilencePermissionSet, error) {
return response, nil
}
svc := SilenceService{
authz: &authz,
}
require.NoError(t, svc.WithAccessControlMetadata(context.Background(), user, silencesWithMetadata...))
for _, silence := range silencesWithMetadata {
assert.Equal(t, response[silence.Silence], *silence.Metadata.Permissions)
}
})
}
func TestWithRuleMetadata(t *testing.T) {
user := ac.BackgroundUser("test", 1, org.RoleNone, nil)
t.Run("Attach rule metadata to silences", func(t *testing.T) {
ruleAuthz := fakes.FakeRuleService{}
ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence accesscontrol.Namespaced) (bool, error) {
return true, nil
}
rules := []*models.AlertRule{
{UID: "rule1", NamespaceUID: "folder1"},
{UID: "rule2", NamespaceUID: "folder2"},
{UID: "rule3", NamespaceUID: "folder3"},
}
ruleStore := ngfakes.NewRuleStore(t)
ruleStore.Rules[1] = rules
svc := SilenceService{
ruleAuthz: &ruleAuthz,
ruleStore: ruleStore,
}
silencesWithMetadata := []*models.SilenceWithMetadata{
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule1", labels.MatchEqual))())},
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule2", labels.MatchEqual))())},
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule3", labels.MatchEqual))())},
}
require.NoError(t, svc.WithRuleMetadata(context.Background(), user, silencesWithMetadata...))
for i, silence := range silencesWithMetadata {
metadata := &models.SilenceRuleMetadata{
RuleUID: rules[i].UID,
RuleTitle: rules[i].Title,
FolderUID: rules[i].NamespaceUID,
}
assert.Equal(t, silence.Metadata, models.SilenceMetadata{RuleMetadata: metadata})
}
})
t.Run("Don't attach full rule metadata if no access or global", func(t *testing.T) {
ruleAuthz := fakes.FakeRuleService{}
ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence accesscontrol.Namespaced) (bool, error) {
return silence.GetNamespaceUID() == "folder1", nil
}
rules := []*models.AlertRule{
{UID: "rule1", NamespaceUID: "folder1"},
{UID: "rule2", NamespaceUID: "folder2"},
{UID: "rule3", NamespaceUID: "folder3"},
}
ruleStore := ngfakes.NewRuleStore(t)
ruleStore.Rules[1] = rules
svc := SilenceService{
ruleAuthz: &ruleAuthz,
ruleStore: ruleStore,
}
silencesWithMetadata := []*models.SilenceWithMetadata{
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule1", labels.MatchEqual))())},
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule2", labels.MatchEqual))())},
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule3", labels.MatchEqual))())},
{Silence: util.Pointer(models.SilenceGen()())},
}
require.NoError(t, svc.WithRuleMetadata(context.Background(), user, silencesWithMetadata...))
assert.Equal(t, silencesWithMetadata[0].Metadata, models.SilenceMetadata{RuleMetadata: &models.SilenceRuleMetadata{ // Attach all metadata.
RuleUID: rules[0].UID,
RuleTitle: rules[0].Title,
FolderUID: rules[0].NamespaceUID,
}})
assert.Equal(t, silencesWithMetadata[1].Metadata, models.SilenceMetadata{RuleMetadata: &models.SilenceRuleMetadata{ // Attach metadata with rule UID regardless of access.
RuleUID: rules[1].UID,
}})
assert.Equal(t, silencesWithMetadata[2].Metadata, models.SilenceMetadata{RuleMetadata: &models.SilenceRuleMetadata{ // Attach metadata with rule UID regardless of access.
RuleUID: rules[2].UID,
}})
assert.Equal(t, silencesWithMetadata[3].Metadata, models.SilenceMetadata{}) // Global silence, no rule metadata.
})
t.Run("Don't check same namespace access more than once", func(t *testing.T) {
ruleAuthz := fakes.FakeRuleService{}
ruleAuthz.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, silence accesscontrol.Namespaced) (bool, error) {
return true, nil
}
rules := []*models.AlertRule{
{UID: "rule1", NamespaceUID: "folder1"},
{UID: "rule2", NamespaceUID: "folder1"},
{UID: "rule3", NamespaceUID: "folder1"},
}
ruleStore := ngfakes.NewRuleStore(t)
ruleStore.Rules[1] = rules
svc := SilenceService{
ruleAuthz: &ruleAuthz,
ruleStore: ruleStore,
}
silencesWithMetadata := []*models.SilenceWithMetadata{
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule1", labels.MatchEqual))())},
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule2", labels.MatchEqual))())},
{Silence: util.Pointer(models.SilenceGen(models.SilenceMuts.WithMatcher(alertingmodels.RuleUIDLabel, "rule3", labels.MatchEqual))())},
}
require.NoError(t, svc.WithRuleMetadata(context.Background(), user, silencesWithMetadata...))
assert.Lenf(t, ruleAuthz.Calls, 1, "HasAccessInFolder should be called only once per namespace")
assert.Equal(t, "HasAccessInFolder", ruleAuthz.Calls[0].MethodName)
assert.Equal(t, "folder1", ruleAuthz.Calls[0].Arguments[2].(accesscontrol.Namespaced).GetNamespaceUID())
})
}

View File

@ -367,6 +367,11 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
}
if len(query.RuleUIDs) > 0 {
args, in := getINSubQueryArgs(query.RuleUIDs)
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
}
if len(query.RuleGroups) > 0 {
args, in := getINSubQueryArgs(query.RuleGroups)
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...)

View File

@ -210,6 +210,9 @@ func (f *RuleStore) ListAlertRules(_ context.Context, q *models.ListAlertRulesQu
if len(q.RuleGroups) > 0 && !slices.Contains(q.RuleGroups, r.RuleGroup) {
continue
}
if len(q.RuleUIDs) > 0 && !slices.Contains(q.RuleUIDs, r.UID) {
continue
}
ruleList = append(ruleList, r)
}