mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
413013a000
commit
09cb3a6048
@ -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 {
|
||||||
|
58
pkg/services/ngalert/accesscontrol/fakes/silences.go
Normal file
58
pkg/services/ngalert/accesscontrol/fakes/silences.go
Normal 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
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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]
|
||||||
|
}
|
||||||
|
@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
163
pkg/services/ngalert/notifier/silence_svc_test.go
Normal file
163
pkg/services/ngalert/notifier/silence_svc_test.go
Normal 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())
|
||||||
|
})
|
||||||
|
}
|
@ -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...)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user