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 AuthorizeDatasourceAccessForRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) error
HasAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) (bool, error) HasAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) (bool, error)
AuthorizeAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) 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 AuthorizeAccessInFolderFunc func(context.Context, identity.Requester, accesscontrol.Namespaced) error
AuthorizeRuleChangesFunc func(context.Context, identity.Requester, *store.GroupDelta) 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 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 { 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}}) s.Calls = append(s.Calls, Call{"AuthorizeAccessInFolder", []interface{}{ctx, user, namespaced}})
if s.AuthorizeAccessInFolderFunc != nil { 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)) 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 { for _, silWithFolder := range silencesWithFolders {
hasAccess, ok := namespacesByAccess[silWithFolder.folderUID] hasAccess, ok := accessCacheByFolder[silWithFolder.folderUID]
if !ok { if !ok {
hasAccess = s.authorizeReadSilence(ctx, user, silWithFolder) == nil hasAccess = s.authorizeReadSilence(ctx, user, silWithFolder) == nil
// Cache non-empty namespaces to avoid repeated checks for the same folder. // Cache non-empty namespaces to avoid repeated checks for the same folder.
if silWithFolder.folderUID != "" { if silWithFolder.folderUID != "" {
namespacesByAccess[silWithFolder.folderUID] = hasAccess accessCacheByFolder[silWithFolder.folderUID] = hasAccess
} }
} }
if 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 // withFolders resolves rule UIDs to folder UIDs for rule-specific silences and returns a list of silenceWithFolder
// that includes rule information, if available. // that includes rule information, if available.
func (s SilenceService) withFolders(ctx context.Context, orgID int64, silences ...*models.Silence) ([]*silenceWithFolder, error) { 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 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 { } else {
require.Equal(t, 0, store.Calls) 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 { } else {
require.Equal(t, 0, store.Calls) 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 { } else {
require.Equal(t, 0, store.Calls) 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 { func testSilence(id string, ruleUID *string) *models.Silence {
s := &models.Silence{ID: &id} s := &models.Silence{ID: &id}
if ruleUID != nil { if ruleUID != nil {

View File

@ -95,11 +95,18 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
api.DatasourceCache, api.DatasourceCache,
NewLotexAM(proxy, logger), NewLotexAM(proxy, logger),
&AlertmanagerSrv{ &AlertmanagerSrv{
crypto: api.MultiOrgAlertmanager.Crypto, crypto: api.MultiOrgAlertmanager.Crypto,
log: logger, log: logger,
ac: api.AccessControl, ac: api.AccessControl,
mam: api.MultiOrgAlertmanager, mam: api.MultiOrgAlertmanager,
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(api.AccessControl, api.RuleStore), api.TransactionManager, logger, api.MultiOrgAlertmanager), silenceSvc: notifier.NewSilenceService(
accesscontrol.NewSilenceService(api.AccessControl, api.RuleStore),
api.TransactionManager,
logger,
api.MultiOrgAlertmanager,
api.RuleStore,
ruleAuthzService,
),
}, },
), m) ), m)
// Register endpoints for proxying to Prometheus-compatible backends. // 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) CreateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error)
UpdateSilence(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 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. // 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 { if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get silence", err) 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. // 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 { if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to list silence", err) 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. // 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"}) 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() log := log.NewNopLogger()
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
ruleStore := ngfakes.NewRuleStore(t) ruleStore := ngfakes.NewRuleStore(t)
ruleAuthzService := accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures()))
return AlertmanagerSrv{ return AlertmanagerSrv{
mam: mam, mam: mam,
crypto: mam.Crypto, crypto: mam.Crypto,
ac: ac, ac: ac,
log: log, 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 package api
import ( import (
"fmt"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "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/models"
) )
// Silence-specific compat functions to convert between API and model types. // Silence-specific compat functions to convert between API and model types.
func SilenceToGettableSilence(s models.Silence) definitions.GettableSilence { func SilenceToGettableGrafanaSilence(s *models.SilenceWithMetadata) definitions.GettableGrafanaSilence {
return definitions.GettableSilence(s) 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 { func SilencesToGettableGrafanaSilences(silences []*models.SilenceWithMetadata) definitions.GettableGrafanaSilences {
res := make(definitions.GettableSilences, 0, len(silences)) res := make(definitions.GettableGrafanaSilences, 0, len(silences))
for _, sil := range silences { for _, sil := range silences {
apiSil := SilenceToGettableSilence(*sil) apiSil := SilenceToGettableGrafanaSilence(sil)
res = append(res, &apiSil) res = append(res, &apiSil)
} }
return res return res
@ -28,3 +54,16 @@ func PostableSilenceToSilence(s definitions.PostableSilence) models.Silence {
Silence: s.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" "time"
"github.com/go-openapi/strfmt" "github.com/go-openapi/strfmt"
"github.com/grafana/alerting/definition"
"github.com/mohae/deepcopy" "github.com/mohae/deepcopy"
amv2 "github.com/prometheus/alertmanager/api/v2/models" amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model" "github.com/prometheus/common/model"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/grafana/alerting/definition"
) )
// swagger:route POST /alertmanager/grafana/config/api/v1/alerts alertmanager RoutePostGrafanaAlertingConfig // swagger:route POST /alertmanager/grafana/config/api/v1/alerts alertmanager RoutePostGrafanaAlertingConfig
@ -182,7 +183,7 @@ import (
// get silences // get silences
// //
// Responses: // Responses:
// 200: gettableSilences // 200: gettableGrafanaSilences
// 400: ValidationError // 400: ValidationError
// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteGetSilences // swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteGetSilences
@ -216,7 +217,7 @@ import (
// get silence // get silence
// //
// Responses: // Responses:
// 200: gettableSilence // 200: gettableGrafanaSilence
// 400: ValidationError // 400: ValidationError
// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence // swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence
@ -389,6 +390,14 @@ type GetDeleteSilenceParams struct {
type GetSilencesParams struct { type GetSilencesParams struct {
// in:query // in:query
Filter []string `json:"filter"` 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 // swagger:model
@ -479,6 +488,55 @@ type GettableSilences = amv2.GettableSilences
// swagger:model gettableSilence // swagger:model gettableSilence
type GettableSilence = amv2.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 // swagger:model gettableAlerts
type GettableAlerts = amv2.GettableAlerts type GettableAlerts = amv2.GettableAlerts

View File

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

View File

@ -2,6 +2,7 @@ package models
import ( import (
amv2 "github.com/prometheus/alertmanager/api/v2/models" amv2 "github.com/prometheus/alertmanager/api/v2/models"
"golang.org/x/exp/maps"
alertingModels "github.com/grafana/alerting/models" alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/alerting/notify" "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. // If IsEqual is nil, it is considered to be true.
return (m.IsEqual == nil || *m.IsEqual) && (m.IsRegex == nil || !*m.IsRegex) 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 ( import (
"context" "context"
"golang.org/x/exp/maps"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/models"
) )
// SilenceService is the authenticated service for managing alertmanager silences. // SilenceService is the authenticated service for managing alertmanager silences.
type SilenceService struct { type SilenceService struct {
authz SilenceAccessControlService authz SilenceAccessControlService
xact transactionManager xact transactionManager
log log.Logger log log.Logger
store SilenceStore 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. // SilenceAccessControlService provides access control for silences.
@ -22,6 +31,7 @@ type SilenceAccessControlService interface {
AuthorizeReadSilence(ctx context.Context, user identity.Requester, 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 AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error
AuthorizeUpdateSilence(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 // 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 DeleteSilence(ctx context.Context, orgID int64, id string) error
} }
type RuleStore interface {
ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error)
}
func NewSilenceService( func NewSilenceService(
authz SilenceAccessControlService, authz SilenceAccessControlService,
xact transactionManager, xact transactionManager,
log log.Logger, log log.Logger,
store SilenceStore, store SilenceStore,
ruleStore RuleStore,
ruleAuthz RuleAccessControlService,
) *SilenceService { ) *SilenceService {
return &SilenceService{ return &SilenceService{
authz: authz, authz: authz,
xact: xact, xact: xact,
log: log, log: log,
store: store, store: store,
ruleStore: ruleStore,
ruleAuthz: ruleAuthz,
} }
} }
// GetSilence retrieves a silence by its ID. // GetSilence retrieves a silence by its ID.
func (s *SilenceService) GetSilence(ctx context.Context, user identity.Requester, silenceID string) (*models.Silence, error) { 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 { if err != nil {
return nil, err 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 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 // 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. // 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) { 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 { if err != nil {
return nil, err return nil, err
} }
return s.authz.FilterByAccess(ctx, user, gettableSilences...) return s.authz.FilterByAccess(ctx, user, silences...)
} }
// CreateSilence creates a new silence. // CreateSilence creates a new silence.
@ -125,3 +143,86 @@ func (s *SilenceService) DeleteSilence(ctx context.Context, user identity.Reques
return nil 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...) 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 { if len(query.RuleGroups) > 0 {
args, in := getINSubQueryArgs(query.RuleGroups) args, in := getINSubQueryArgs(query.RuleGroups)
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...) 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) { if len(q.RuleGroups) > 0 && !slices.Contains(q.RuleGroups, r.RuleGroup) {
continue continue
} }
if len(q.RuleUIDs) > 0 && !slices.Contains(q.RuleUIDs, r.UID) {
continue
}
ruleList = append(ruleList, r) ruleList = append(ruleList, r)
} }