mirror of
https://github.com/grafana/grafana.git
synced 2024-11-29 04:04:00 -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
|
||||
HasAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) (bool, error)
|
||||
AuthorizeAccessToRuleGroupFunc func(context.Context, identity.Requester, models.RulesGroup) error
|
||||
HasAccessInFolderFunc func(context.Context, identity.Requester, accesscontrol.Namespaced) (bool, error)
|
||||
AuthorizeAccessInFolderFunc func(context.Context, identity.Requester, accesscontrol.Namespaced) error
|
||||
AuthorizeRuleChangesFunc func(context.Context, identity.Requester, *store.GroupDelta) error
|
||||
|
||||
@ -76,6 +77,14 @@ func (s *FakeRuleService) AuthorizeAccessToRuleGroup(ctx context.Context, user i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FakeRuleService) HasAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) (bool, error) {
|
||||
s.Calls = append(s.Calls, Call{"HasAccessInFolder", []interface{}{ctx, user, namespaced}})
|
||||
if s.HasAccessInFolderFunc != nil {
|
||||
return s.HasAccessInFolderFunc(ctx, user, namespaced)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *FakeRuleService) AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced accesscontrol.Namespaced) error {
|
||||
s.Calls = append(s.Calls, Call{"AuthorizeAccessInFolder", []interface{}{ctx, user, namespaced}})
|
||||
if s.AuthorizeAccessInFolderFunc != nil {
|
||||
|
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))
|
||||
namespacesByAccess := make(map[string]bool) // caches results of permissions check for each namespace to avoid repeated checks for the same folder
|
||||
accessCacheByFolder := make(map[string]bool)
|
||||
for _, silWithFolder := range silencesWithFolders {
|
||||
hasAccess, ok := namespacesByAccess[silWithFolder.folderUID]
|
||||
hasAccess, ok := accessCacheByFolder[silWithFolder.folderUID]
|
||||
if !ok {
|
||||
hasAccess = s.authorizeReadSilence(ctx, user, silWithFolder) == nil
|
||||
|
||||
// Cache non-empty namespaces to avoid repeated checks for the same folder.
|
||||
if silWithFolder.folderUID != "" {
|
||||
namespacesByAccess[silWithFolder.folderUID] = hasAccess
|
||||
accessCacheByFolder[silWithFolder.folderUID] = hasAccess
|
||||
}
|
||||
}
|
||||
if hasAccess {
|
||||
@ -284,6 +284,66 @@ func (s SilenceService) authorizeUpdateSilence(ctx context.Context, user identit
|
||||
})
|
||||
}
|
||||
|
||||
// SilenceAccess returns the permission sets for a slice of silences. The permission set includes read, write, and
|
||||
// create which corresponds the given user being able to read, write, and create each given silence, respectively.
|
||||
func (s SilenceService) SilenceAccess(ctx context.Context, user identity.Requester, silences []*models.Silence) (map[*models.Silence]models.SilencePermissionSet, error) {
|
||||
basePerms := make(models.SilencePermissionSet, 3)
|
||||
canReadAll, err := s.authorizeReadSilencePreConditions(ctx, user)
|
||||
if err != nil || canReadAll {
|
||||
basePerms[models.SilencePermissionRead] = err == nil
|
||||
}
|
||||
canUpdateAny, err := s.authorizeUpdateSilencePreConditions(ctx, user)
|
||||
if err != nil || canUpdateAny {
|
||||
basePerms[models.SilencePermissionWrite] = err == nil
|
||||
}
|
||||
canCreateAny, err := s.authorizeCreateSilencePreConditions(ctx, user)
|
||||
if err != nil || canCreateAny {
|
||||
basePerms[models.SilencePermissionCreate] = err == nil
|
||||
}
|
||||
|
||||
if basePerms.AllSet() {
|
||||
// Shortcut for the case when all permissions are known based on preconditions. We don't need to hit the database to find folder UIDs.
|
||||
return withPermissionSet(silences, basePerms), nil
|
||||
}
|
||||
|
||||
silencesWithFolders, err := s.withFolders(ctx, user.GetOrgID(), silences...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[*models.Silence]models.SilencePermissionSet, len(silences))
|
||||
accessCacheByFolder := make(map[string]models.SilencePermissionSet)
|
||||
for _, silWithFolder := range silencesWithFolders {
|
||||
if perms, ok := accessCacheByFolder[silWithFolder.folderUID]; ok {
|
||||
result[silWithFolder.Silence] = perms.Clone()
|
||||
continue
|
||||
}
|
||||
|
||||
permSet := basePerms.Clone()
|
||||
if _, ok := permSet[models.SilencePermissionRead]; !ok {
|
||||
err := s.authorizeReadSilence(ctx, user, silWithFolder)
|
||||
permSet[models.SilencePermissionRead] = err == nil
|
||||
}
|
||||
|
||||
if _, ok := permSet[models.SilencePermissionWrite]; !ok {
|
||||
err := s.authorizeUpdateSilence(ctx, user, silWithFolder)
|
||||
permSet[models.SilencePermissionWrite] = err == nil
|
||||
}
|
||||
|
||||
if _, ok := permSet[models.SilencePermissionCreate]; !ok {
|
||||
err := s.authorizeCreateSilence(ctx, user, silWithFolder)
|
||||
permSet[models.SilencePermissionCreate] = err == nil
|
||||
}
|
||||
|
||||
result[silWithFolder.Silence] = permSet
|
||||
// Cache non-empty namespaces to avoid repeated checks for the same folder.
|
||||
if silWithFolder.folderUID != "" {
|
||||
accessCacheByFolder[silWithFolder.folderUID] = permSet
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// withFolders resolves rule UIDs to folder UIDs for rule-specific silences and returns a list of silenceWithFolder
|
||||
// that includes rule information, if available.
|
||||
func (s SilenceService) withFolders(ctx context.Context, orgID int64, silences ...*models.Silence) ([]*silenceWithFolder, error) {
|
||||
@ -313,3 +373,11 @@ func (s SilenceService) withFolders(ctx context.Context, orgID int64, silences .
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func withPermissionSet(silences []*models.Silence, perms models.SilencePermissionSet) map[*models.Silence]models.SilencePermissionSet {
|
||||
result := make(map[*models.Silence]models.SilencePermissionSet, len(silences))
|
||||
for _, silence := range silences {
|
||||
result[silence] = perms.Clone()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
@ -212,6 +212,12 @@ func TestAuthorizeReadSilence(t *testing.T) {
|
||||
} else {
|
||||
require.Equal(t, 0, store.Calls)
|
||||
}
|
||||
|
||||
// Verify SilenceAccess.
|
||||
permSets, err := svc.SilenceAccess(context.Background(), testCase.user, []*models.Silence{silence})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, permSets, 1)
|
||||
assert.Equal(t, testCase.expectedErr == nil, permSets[silence].Has(models.SilencePermissionRead))
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -383,6 +389,12 @@ func TestAuthorizeCreateSilence(t *testing.T) {
|
||||
} else {
|
||||
require.Equal(t, 0, store.Calls)
|
||||
}
|
||||
|
||||
// Verify SilenceAccess.
|
||||
permSets, err := svc.SilenceAccess(context.Background(), testCase.user, []*models.Silence{silence})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, permSets, 1)
|
||||
assert.Equal(t, expectedErr == nil, permSets[silence].Has(models.SilencePermissionCreate))
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -554,12 +566,275 @@ func TestAuthorizeUpdateSilence(t *testing.T) {
|
||||
} else {
|
||||
require.Equal(t, 0, store.Calls)
|
||||
}
|
||||
|
||||
// Verify SilenceAccess.
|
||||
permSets, err := svc.SilenceAccess(context.Background(), testCase.user, []*models.Silence{silence})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, permSets, 1)
|
||||
assert.Equal(t, expectedErr == nil, permSets[silence].Has(models.SilencePermissionWrite))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSilenceAccess(t *testing.T) {
|
||||
global := testSilence("global", nil)
|
||||
ruleSilence1 := testSilence("rule-1", utils.Pointer("rule-1-uid"))
|
||||
folder1 := "rule-1-folder-uid"
|
||||
folder1Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder1)
|
||||
ruleSilence2 := testSilence("rule-2", utils.Pointer("rule-2-uid"))
|
||||
folder2 := "rule-2-folder-uid"
|
||||
folder2Scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2)
|
||||
notFoundRule := testSilence("unknown-rule", utils.Pointer("unknown-rule-uid"))
|
||||
|
||||
silences := []*models.Silence{
|
||||
global,
|
||||
ruleSilence1,
|
||||
ruleSilence2,
|
||||
notFoundRule,
|
||||
}
|
||||
|
||||
type override struct {
|
||||
expectedPermissions models.SilencePermissionSet
|
||||
}
|
||||
|
||||
permit := func(permission ...models.SilencePermission) override {
|
||||
o := override{expectedPermissions: models.SilencePermissionSet{}}
|
||||
for _, p := range permission {
|
||||
o.expectedPermissions[p] = true
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
deny := func(permission ...models.SilencePermission) override {
|
||||
o := override{expectedPermissions: models.SilencePermissionSet{}}
|
||||
for _, p := range permission {
|
||||
o.expectedPermissions[p] = false
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user identity.Requester
|
||||
expectedPermissions models.SilencePermissionSet
|
||||
expectedDbAccess bool
|
||||
overrides map[*models.Silence]override
|
||||
}{
|
||||
{
|
||||
name: "not authorized without permissions",
|
||||
user: newUser(),
|
||||
},
|
||||
{
|
||||
name: "instance read gives read access to everything",
|
||||
user: newUser(ac.Permission{Action: instancesRead}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "silence wildcard read gives read access to everything",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "silence read in folders gives read access to global and folders",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceRead, Scope: folder2Scope}),
|
||||
overrides: map[*models.Silence]override{
|
||||
global: permit(models.SilencePermissionRead),
|
||||
ruleSilence1: permit(models.SilencePermissionRead),
|
||||
ruleSilence2: permit(models.SilencePermissionRead),
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "instance reade+write+create can do everything",
|
||||
user: newUser(ac.Permission{Action: instancesRead}, ac.Permission{Action: instancesWrite}, ac.Permission{Action: instancesCreate}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionCreate: true,
|
||||
models.SilencePermissionWrite: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "silence wildcard read + instance write+create can do everything",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: instancesWrite}, ac.Permission{Action: instancesCreate}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionCreate: true,
|
||||
models.SilencePermissionWrite: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance readr+write can read and write",
|
||||
user: newUser(ac.Permission{Action: instancesRead}, ac.Permission{Action: instancesWrite}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionWrite: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "silence wildcard read + instance write can read and write",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: instancesWrite}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionWrite: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance reader+create can read and create",
|
||||
user: newUser(ac.Permission{Action: instancesRead}, ac.Permission{Action: instancesCreate}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionCreate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "silence wildcard read + instance create can read and create",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: instancesCreate}),
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionCreate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cannot write/create without read - instance permissions",
|
||||
user: newUser(ac.Permission{Action: instancesWrite}, ac.Permission{Action: instancesCreate}),
|
||||
expectedPermissions: models.SilencePermissionSet{},
|
||||
},
|
||||
{
|
||||
name: "cannot write/create without read - silence permissions",
|
||||
user: newUser(ac.Permission{Action: silenceWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: silenceCreate, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
|
||||
expectedPermissions: models.SilencePermissionSet{},
|
||||
},
|
||||
{
|
||||
name: "instance read + silence write in folder",
|
||||
user: newUser(ac.Permission{Action: silenceWrite, Scope: folder1Scope}, ac.Permission{Action: instancesRead}),
|
||||
overrides: map[*models.Silence]override{
|
||||
ruleSilence1: permit(models.SilencePermissionWrite),
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "instance read + silence create in folder",
|
||||
user: newUser(ac.Permission{Action: silenceCreate, Scope: folder1Scope}, ac.Permission{Action: instancesRead}),
|
||||
overrides: map[*models.Silence]override{
|
||||
ruleSilence1: permit(models.SilencePermissionCreate),
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read in folder + instance write also provides global write",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesWrite}),
|
||||
overrides: map[*models.Silence]override{
|
||||
global: permit(models.SilencePermissionRead, models.SilencePermissionWrite),
|
||||
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionWrite),
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read in folder + instance create also provides global create",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: instancesCreate}),
|
||||
overrides: map[*models.Silence]override{
|
||||
global: permit(models.SilencePermissionRead, models.SilencePermissionCreate),
|
||||
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionCreate),
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence wildcard write doesn't provide global write but does provide unknown rule write",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: silenceWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
|
||||
overrides: map[*models.Silence]override{
|
||||
global: deny(models.SilencePermissionWrite),
|
||||
notFoundRule: deny(models.SilencePermissionWrite), // This is arguable, can consider changing this in the future.
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionWrite: true,
|
||||
},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence wildcard create doesn't provide global create but does provide unknown rule create",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}, ac.Permission{Action: silenceCreate, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()}),
|
||||
overrides: map[*models.Silence]override{
|
||||
global: deny(models.SilencePermissionCreate),
|
||||
notFoundRule: deny(models.SilencePermissionCreate), // This is arguable, can consider changing this in the future.
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{
|
||||
models.SilencePermissionRead: true,
|
||||
models.SilencePermissionCreate: true,
|
||||
},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read + write in single folder",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceWrite, Scope: folder1Scope}),
|
||||
overrides: map[*models.Silence]override{
|
||||
global: permit(models.SilencePermissionRead),
|
||||
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionWrite),
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
{
|
||||
name: "silence read + create in single folder",
|
||||
user: newUser(ac.Permission{Action: silenceRead, Scope: folder1Scope}, ac.Permission{Action: silenceCreate, Scope: folder1Scope}),
|
||||
overrides: map[*models.Silence]override{
|
||||
global: permit(models.SilencePermissionRead),
|
||||
ruleSilence1: permit(models.SilencePermissionRead, models.SilencePermissionCreate),
|
||||
},
|
||||
expectedPermissions: models.SilencePermissionSet{},
|
||||
expectedDbAccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ac := &recordingAccessControlFake{}
|
||||
store := &fakeRuleUIDToNamespaceStore{
|
||||
Response: map[string]string{
|
||||
*ruleSilence1.GetRuleUID(): folder1,
|
||||
*ruleSilence2.GetRuleUID(): folder2,
|
||||
},
|
||||
}
|
||||
svc := NewSilenceService(ac, store)
|
||||
|
||||
perms, err := svc.SilenceAccess(context.Background(), tc.user, silences)
|
||||
assert.NoError(t, err)
|
||||
if tc.expectedDbAccess {
|
||||
assert.Equalf(t, 1, store.Calls, "expected 1 db access, but got %d store calls", store.Calls)
|
||||
} else {
|
||||
assert.Equalf(t, 0, store.Calls, "expected no db access, but got %d store calls", store.Calls)
|
||||
}
|
||||
|
||||
for _, silence := range silences {
|
||||
expectedPermissions := tc.expectedPermissions.Clone()
|
||||
if s, ok := tc.overrides[silence]; ok {
|
||||
for k, v := range s.expectedPermissions {
|
||||
expectedPermissions[k] = v
|
||||
}
|
||||
}
|
||||
for _, permission := range models.SilencePermissions() {
|
||||
assert.Equalf(t, expectedPermissions.Has(permission), perms[silence].Has(permission), "expected %s=%t permission for silence %s but got %t", permission, expectedPermissions.Has(permission), *silence.ID, perms[silence].Has(permission))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testSilence(id string, ruleUID *string) *models.Silence {
|
||||
s := &models.Silence{ID: &id}
|
||||
if ruleUID != nil {
|
||||
|
@ -95,11 +95,18 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
|
||||
api.DatasourceCache,
|
||||
NewLotexAM(proxy, logger),
|
||||
&AlertmanagerSrv{
|
||||
crypto: api.MultiOrgAlertmanager.Crypto,
|
||||
log: logger,
|
||||
ac: api.AccessControl,
|
||||
mam: api.MultiOrgAlertmanager,
|
||||
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(api.AccessControl, api.RuleStore), api.TransactionManager, logger, api.MultiOrgAlertmanager),
|
||||
crypto: api.MultiOrgAlertmanager.Crypto,
|
||||
log: logger,
|
||||
ac: api.AccessControl,
|
||||
mam: api.MultiOrgAlertmanager,
|
||||
silenceSvc: notifier.NewSilenceService(
|
||||
accesscontrol.NewSilenceService(api.AccessControl, api.RuleStore),
|
||||
api.TransactionManager,
|
||||
logger,
|
||||
api.MultiOrgAlertmanager,
|
||||
api.RuleStore,
|
||||
ruleAuthzService,
|
||||
),
|
||||
},
|
||||
), m)
|
||||
// Register endpoints for proxying to Prometheus-compatible backends.
|
||||
|
@ -21,6 +21,8 @@ type SilenceService interface {
|
||||
CreateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error)
|
||||
UpdateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error)
|
||||
DeleteSilence(ctx context.Context, user identity.Requester, silenceID string) error
|
||||
WithAccessControlMetadata(ctx context.Context, user identity.Requester, silencesWithMetadata ...*models.SilenceWithMetadata) error
|
||||
WithRuleMetadata(ctx context.Context, user identity.Requester, silences ...*models.SilenceWithMetadata) error
|
||||
}
|
||||
|
||||
// RouteGetSilence is the single silence GET endpoint for Grafana AM.
|
||||
@ -29,7 +31,22 @@ func (srv AlertmanagerSrv) RouteGetSilence(c *contextmodel.ReqContext, silenceID
|
||||
if err != nil {
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get silence", err)
|
||||
}
|
||||
return response.JSON(http.StatusOK, SilenceToGettableSilence(*silence))
|
||||
|
||||
silenceWithMetadata := &models.SilenceWithMetadata{
|
||||
Silence: silence,
|
||||
}
|
||||
if c.QueryBool("accesscontrol") {
|
||||
if err := srv.silenceSvc.WithAccessControlMetadata(c.Req.Context(), c.SignedInUser, silenceWithMetadata); err != nil {
|
||||
srv.log.Error("failed to get silence access control metadata", "silenceID", silenceID, "error", err)
|
||||
}
|
||||
}
|
||||
if c.QueryBool("ruleMetadata") {
|
||||
if err := srv.silenceSvc.WithRuleMetadata(c.Req.Context(), c.SignedInUser, silenceWithMetadata); err != nil {
|
||||
srv.log.Error("failed to get silence rule metadata", "silenceID", silenceID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, SilenceToGettableGrafanaSilence(silenceWithMetadata))
|
||||
}
|
||||
|
||||
// RouteGetSilences is the silence list GET endpoint for Grafana AM.
|
||||
@ -38,7 +55,19 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *contextmodel.ReqContext) response
|
||||
if err != nil {
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to list silence", err)
|
||||
}
|
||||
return response.JSON(http.StatusOK, SilencesToGettableSilences(silences))
|
||||
|
||||
silencesWithMetadata := withEmptyMetadata(silences...)
|
||||
if c.QueryBool("accesscontrol") {
|
||||
if err := srv.silenceSvc.WithAccessControlMetadata(c.Req.Context(), c.SignedInUser, silencesWithMetadata...); err != nil {
|
||||
srv.log.Error("failed to get silence access control metadata", "error", err)
|
||||
}
|
||||
}
|
||||
if c.QueryBool("ruleMetadata") {
|
||||
if err := srv.silenceSvc.WithRuleMetadata(c.Req.Context(), c.SignedInUser, silencesWithMetadata...); err != nil {
|
||||
srv.log.Error("failed to get silence rule metadata", "error", err)
|
||||
}
|
||||
}
|
||||
return response.JSON(http.StatusOK, SilencesToGettableGrafanaSilences(silencesWithMetadata))
|
||||
}
|
||||
|
||||
// RouteCreateSilence is the silence POST (create + update) endpoint for Grafana AM.
|
||||
@ -69,3 +98,15 @@ func (srv AlertmanagerSrv) RouteDeleteSilence(c *contextmodel.ReqContext, silenc
|
||||
}
|
||||
return response.JSON(http.StatusOK, util.DynMap{"message": "silence deleted"})
|
||||
}
|
||||
|
||||
// withEmptyMetadata creates a slice of SilenceWithMetadata from a slice of Silence where the metadata for each silence
|
||||
// is empty.
|
||||
func withEmptyMetadata(silences ...*models.Silence) []*models.SilenceWithMetadata {
|
||||
silencesWithMetadata := make([]*models.SilenceWithMetadata, 0, len(silences))
|
||||
for _, silence := range silences {
|
||||
silencesWithMetadata = append(silencesWithMetadata, &models.SilenceWithMetadata{
|
||||
Silence: silence,
|
||||
})
|
||||
}
|
||||
return silencesWithMetadata
|
||||
}
|
||||
|
@ -633,12 +633,13 @@ func createSut(t *testing.T) AlertmanagerSrv {
|
||||
log := log.NewNopLogger()
|
||||
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
|
||||
ruleStore := ngfakes.NewRuleStore(t)
|
||||
ruleAuthzService := accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures()))
|
||||
return AlertmanagerSrv{
|
||||
mam: mam,
|
||||
crypto: mam.Crypto,
|
||||
ac: ac,
|
||||
log: log,
|
||||
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(ac, ruleStore), ruleStore, log, mam),
|
||||
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(ac, ruleStore), ruleStore, log, mam, ruleStore, ruleAuthzService),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,20 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
// Silence-specific compat functions to convert between API and model types.
|
||||
|
||||
func SilenceToGettableSilence(s models.Silence) definitions.GettableSilence {
|
||||
return definitions.GettableSilence(s)
|
||||
func SilenceToGettableGrafanaSilence(s *models.SilenceWithMetadata) definitions.GettableGrafanaSilence {
|
||||
gettable := definitions.GettableGrafanaSilence{
|
||||
GettableSilence: (*definitions.GettableSilence)(s.Silence),
|
||||
}
|
||||
|
||||
if s.Metadata.Permissions != nil {
|
||||
gettable.Permissions = make(map[definitions.SilencePermission]bool, len(*s.Metadata.Permissions))
|
||||
for _, permission := range models.SilencePermissions() {
|
||||
p, err := SilencePermissionToAPI(permission)
|
||||
if err != nil {
|
||||
// Skip unknown permissions in response.
|
||||
continue
|
||||
}
|
||||
gettable.Permissions[p] = s.Metadata.Permissions.Has(permission)
|
||||
}
|
||||
}
|
||||
|
||||
if s.Metadata.RuleMetadata != nil {
|
||||
gettable.Metadata = &definitions.SilenceMetadata{
|
||||
RuleUID: s.Metadata.RuleMetadata.RuleUID,
|
||||
RuleTitle: s.Metadata.RuleMetadata.RuleTitle,
|
||||
FolderUID: s.Metadata.RuleMetadata.FolderUID,
|
||||
}
|
||||
}
|
||||
|
||||
return gettable
|
||||
}
|
||||
|
||||
func SilencesToGettableSilences(silences []*models.Silence) definitions.GettableSilences {
|
||||
res := make(definitions.GettableSilences, 0, len(silences))
|
||||
func SilencesToGettableGrafanaSilences(silences []*models.SilenceWithMetadata) definitions.GettableGrafanaSilences {
|
||||
res := make(definitions.GettableGrafanaSilences, 0, len(silences))
|
||||
for _, sil := range silences {
|
||||
apiSil := SilenceToGettableSilence(*sil)
|
||||
apiSil := SilenceToGettableGrafanaSilence(sil)
|
||||
res = append(res, &apiSil)
|
||||
}
|
||||
return res
|
||||
@ -28,3 +54,16 @@ func PostableSilenceToSilence(s definitions.PostableSilence) models.Silence {
|
||||
Silence: s.Silence,
|
||||
}
|
||||
}
|
||||
|
||||
func SilencePermissionToAPI(p models.SilencePermission) (definitions.SilencePermission, error) {
|
||||
switch p {
|
||||
case models.SilencePermissionRead:
|
||||
return definitions.SilencePermissionRead, nil
|
||||
case models.SilencePermissionCreate:
|
||||
return definitions.SilencePermissionCreate, nil
|
||||
case models.SilencePermissionWrite:
|
||||
return definitions.SilencePermissionWrite, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown permission: %s", p)
|
||||
}
|
||||
}
|
||||
|
@ -8,12 +8,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/grafana/alerting/definition"
|
||||
"github.com/mohae/deepcopy"
|
||||
amv2 "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/grafana/alerting/definition"
|
||||
)
|
||||
|
||||
// swagger:route POST /alertmanager/grafana/config/api/v1/alerts alertmanager RoutePostGrafanaAlertingConfig
|
||||
@ -182,7 +183,7 @@ import (
|
||||
// get silences
|
||||
//
|
||||
// Responses:
|
||||
// 200: gettableSilences
|
||||
// 200: gettableGrafanaSilences
|
||||
// 400: ValidationError
|
||||
|
||||
// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silences alertmanager RouteGetSilences
|
||||
@ -216,7 +217,7 @@ import (
|
||||
// get silence
|
||||
//
|
||||
// Responses:
|
||||
// 200: gettableSilence
|
||||
// 200: gettableGrafanaSilence
|
||||
// 400: ValidationError
|
||||
|
||||
// swagger:route GET /alertmanager/{DatasourceUID}/api/v2/silence/{SilenceId} alertmanager RouteGetSilence
|
||||
@ -389,6 +390,14 @@ type GetDeleteSilenceParams struct {
|
||||
type GetSilencesParams struct {
|
||||
// in:query
|
||||
Filter []string `json:"filter"`
|
||||
// Return rule metadata with silence.
|
||||
// in:query
|
||||
// required:false
|
||||
RuleMetadata bool `json:"ruleMetadata"`
|
||||
// Return access control metadata with silence.
|
||||
// in:query
|
||||
// required:false
|
||||
AccessControl bool `json:"accesscontrol"`
|
||||
}
|
||||
|
||||
// swagger:model
|
||||
@ -479,6 +488,55 @@ type GettableSilences = amv2.GettableSilences
|
||||
// swagger:model gettableSilence
|
||||
type GettableSilence = amv2.GettableSilence
|
||||
|
||||
// swagger:model gettableGrafanaSilence
|
||||
type GettableGrafanaSilence struct {
|
||||
*GettableSilence `json:",inline"`
|
||||
Metadata *SilenceMetadata `json:"metadata,omitempty"`
|
||||
// example: {"read": true, "write": false, "create": false}
|
||||
Permissions map[SilencePermission]bool `json:"accessControl,omitempty"`
|
||||
}
|
||||
|
||||
type SilenceMetadata struct {
|
||||
RuleUID string `json:"rule_uid,omitempty"`
|
||||
RuleTitle string `json:"rule_title,omitempty"`
|
||||
FolderUID string `json:"folder_uid,omitempty"`
|
||||
}
|
||||
|
||||
type SilencePermission string
|
||||
|
||||
const (
|
||||
SilencePermissionRead SilencePermission = "read"
|
||||
SilencePermissionCreate SilencePermission = "create"
|
||||
SilencePermissionWrite SilencePermission = "write"
|
||||
)
|
||||
|
||||
// Correctly embed the GettableSilence into the GettableGrafanaSilence struct. This is needed because GettableSilence
|
||||
// has a custom UnmarshalJSON method.
|
||||
func (s GettableGrafanaSilence) MarshalJSON() ([]byte, error) {
|
||||
gettable, err := json.Marshal(s.GettableSilence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(gettable, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.Metadata != nil {
|
||||
data["metadata"] = s.Metadata
|
||||
}
|
||||
|
||||
if s.Permissions != nil {
|
||||
data["accessControl"] = s.Permissions
|
||||
}
|
||||
|
||||
return json.Marshal(data)
|
||||
}
|
||||
|
||||
// swagger:model gettableGrafanaSilences
|
||||
type GettableGrafanaSilences []*GettableGrafanaSilence
|
||||
|
||||
// swagger:model gettableAlerts
|
||||
type GettableAlerts = amv2.GettableAlerts
|
||||
|
||||
|
@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
@ -609,6 +610,7 @@ type GetAlertRulesGroupByRuleUIDQuery struct {
|
||||
// ListAlertRulesQuery is the query for listing alert rules
|
||||
type ListAlertRulesQuery struct {
|
||||
OrgID int64
|
||||
RuleUIDs []string
|
||||
NamespaceUIDs []string
|
||||
ExcludeOrgs []int64
|
||||
RuleGroups []string
|
||||
|
@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
amv2 "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
"github.com/grafana/alerting/notify"
|
||||
@ -35,3 +36,63 @@ func isEqualMatcher(m amv2.Matcher) bool {
|
||||
// If IsEqual is nil, it is considered to be true.
|
||||
return (m.IsEqual == nil || *m.IsEqual) && (m.IsRegex == nil || !*m.IsRegex)
|
||||
}
|
||||
|
||||
// SilenceWithMetadata is a helper type for managing a silence with associated metadata.
|
||||
type SilenceWithMetadata struct {
|
||||
*Silence
|
||||
Metadata SilenceMetadata
|
||||
}
|
||||
|
||||
// SilenceMetadata contains metadata about a silence. Fields are pointers to allow for metadata to be optionally loaded.
|
||||
type SilenceMetadata struct {
|
||||
RuleMetadata *SilenceRuleMetadata
|
||||
Permissions *SilencePermissionSet
|
||||
}
|
||||
|
||||
// SilenceRuleMetadata contains metadata about the rule associated with a silence.
|
||||
type SilenceRuleMetadata struct {
|
||||
RuleUID string
|
||||
RuleTitle string
|
||||
FolderUID string
|
||||
}
|
||||
|
||||
// SilencePermission is a type for representing a silence permission.
|
||||
type SilencePermission string
|
||||
|
||||
const (
|
||||
SilencePermissionRead SilencePermission = "read"
|
||||
SilencePermissionCreate SilencePermission = "create"
|
||||
SilencePermissionWrite SilencePermission = "write"
|
||||
)
|
||||
|
||||
// SilencePermissions returns all possible silence permissions.
|
||||
func SilencePermissions() [3]SilencePermission {
|
||||
return [3]SilencePermission{
|
||||
SilencePermissionRead,
|
||||
SilencePermissionCreate,
|
||||
SilencePermissionWrite,
|
||||
}
|
||||
}
|
||||
|
||||
// SilencePermissionSet represents a set of permissions for a silence.
|
||||
type SilencePermissionSet map[SilencePermission]bool
|
||||
|
||||
// Clone returns a deep copy of the permission set.
|
||||
func (p SilencePermissionSet) Clone() SilencePermissionSet {
|
||||
return maps.Clone(p)
|
||||
}
|
||||
|
||||
// AllSet returns true if all possible permissions are set.
|
||||
func (p SilencePermissionSet) AllSet() bool {
|
||||
for _, permission := range SilencePermissions() {
|
||||
if _, ok := p[permission]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Has returns true if the given permission is allowed in the set.
|
||||
func (p SilencePermissionSet) Has(permission SilencePermission) bool {
|
||||
return p[permission]
|
||||
}
|
||||
|
@ -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 (
|
||||
"context"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
// SilenceService is the authenticated service for managing alertmanager silences.
|
||||
type SilenceService struct {
|
||||
authz SilenceAccessControlService
|
||||
xact transactionManager
|
||||
log log.Logger
|
||||
store SilenceStore
|
||||
authz SilenceAccessControlService
|
||||
xact transactionManager
|
||||
log log.Logger
|
||||
store SilenceStore
|
||||
ruleStore RuleStore
|
||||
ruleAuthz RuleAccessControlService
|
||||
}
|
||||
|
||||
type RuleAccessControlService interface {
|
||||
HasAccessInFolder(ctx context.Context, user identity.Requester, rule accesscontrol.Namespaced) (bool, error)
|
||||
}
|
||||
|
||||
// SilenceAccessControlService provides access control for silences.
|
||||
@ -22,6 +31,7 @@ type SilenceAccessControlService interface {
|
||||
AuthorizeReadSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error
|
||||
AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error
|
||||
AuthorizeUpdateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error
|
||||
SilenceAccess(ctx context.Context, user identity.Requester, silences []*models.Silence) (map[*models.Silence]models.SilencePermissionSet, error)
|
||||
}
|
||||
|
||||
// SilenceStore is the interface for storing and retrieving silences. Currently, this is implemented by
|
||||
@ -34,43 +44,51 @@ type SilenceStore interface {
|
||||
DeleteSilence(ctx context.Context, orgID int64, id string) error
|
||||
}
|
||||
|
||||
type RuleStore interface {
|
||||
ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error)
|
||||
}
|
||||
|
||||
func NewSilenceService(
|
||||
authz SilenceAccessControlService,
|
||||
xact transactionManager,
|
||||
log log.Logger,
|
||||
store SilenceStore,
|
||||
ruleStore RuleStore,
|
||||
ruleAuthz RuleAccessControlService,
|
||||
) *SilenceService {
|
||||
return &SilenceService{
|
||||
authz: authz,
|
||||
xact: xact,
|
||||
log: log,
|
||||
store: store,
|
||||
authz: authz,
|
||||
xact: xact,
|
||||
log: log,
|
||||
store: store,
|
||||
ruleStore: ruleStore,
|
||||
ruleAuthz: ruleAuthz,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSilence retrieves a silence by its ID.
|
||||
func (s *SilenceService) GetSilence(ctx context.Context, user identity.Requester, silenceID string) (*models.Silence, error) {
|
||||
gettableSilence, err := s.store.GetSilence(ctx, user.GetOrgID(), silenceID)
|
||||
silence, err := s.store.GetSilence(ctx, user.GetOrgID(), silenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.authz.AuthorizeReadSilence(ctx, user, gettableSilence); err != nil {
|
||||
if err := s.authz.AuthorizeReadSilence(ctx, user, silence); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gettableSilence, nil
|
||||
return silence, nil
|
||||
}
|
||||
|
||||
// ListSilences retrieves all silences that match the given filter. This will include all rule-specific silences that
|
||||
// the user has access to as well as all general silences.
|
||||
func (s *SilenceService) ListSilences(ctx context.Context, user identity.Requester, filter []string) ([]*models.Silence, error) {
|
||||
gettableSilences, err := s.store.ListSilences(ctx, user.GetOrgID(), filter)
|
||||
silences, err := s.store.ListSilences(ctx, user.GetOrgID(), filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.authz.FilterByAccess(ctx, user, gettableSilences...)
|
||||
return s.authz.FilterByAccess(ctx, user, silences...)
|
||||
}
|
||||
|
||||
// CreateSilence creates a new silence.
|
||||
@ -125,3 +143,86 @@ func (s *SilenceService) DeleteSilence(ctx context.Context, user identity.Reques
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAccessControlMetadata adds access control metadata to the given SilenceWithMetadata.
|
||||
func (s *SilenceService) WithAccessControlMetadata(ctx context.Context, user identity.Requester, silencesWithMetadata ...*models.SilenceWithMetadata) error {
|
||||
silences := make([]*models.Silence, 0, len(silencesWithMetadata))
|
||||
for _, silence := range silencesWithMetadata {
|
||||
silences = append(silences, silence.Silence)
|
||||
}
|
||||
permissions, err := s.authz.SilenceAccess(ctx, user, silences)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(permissions) != len(silences) {
|
||||
s.log.Warn("failed to get metadata for all silences")
|
||||
}
|
||||
|
||||
for _, silence := range silencesWithMetadata {
|
||||
if perms, ok := permissions[silence.Silence]; ok {
|
||||
silence.Metadata.Permissions = &perms
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithRuleMetadata adds rule metadata to the given SilenceWithMetadata.
|
||||
func (s *SilenceService) WithRuleMetadata(ctx context.Context, user identity.Requester, silences ...*models.SilenceWithMetadata) error {
|
||||
byRuleUID := make(map[string][]*models.SilenceWithMetadata, len(silences))
|
||||
for _, silence := range silences {
|
||||
ruleUID := silence.GetRuleUID()
|
||||
if ruleUID != nil {
|
||||
byRuleUID[*ruleUID] = append(byRuleUID[*ruleUID], silence)
|
||||
silence.Metadata.RuleMetadata = &models.SilenceRuleMetadata{ // Attach metadata with rule UID regardless of access.
|
||||
RuleUID: *ruleUID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(byRuleUID) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
q := models.ListAlertRulesQuery{
|
||||
RuleUIDs: maps.Keys(byRuleUID),
|
||||
OrgID: user.GetOrgID(),
|
||||
}
|
||||
|
||||
rules, err := s.ruleStore.ListAlertRules(ctx, &q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessCacheByFolder := make(map[string]bool)
|
||||
for _, rule := range rules {
|
||||
// TODO: Preferably silence service should not need to know about the internal details of rule access control.
|
||||
// This can be improved by adding a method to ruleAuthz that does the filtering itself or a method that exposes
|
||||
// an access fingerprint for a rule that callers can use to do their own caching.
|
||||
fp := rule.NamespaceUID
|
||||
canAccess, ok := accessCacheByFolder[fp]
|
||||
if !ok {
|
||||
var err error
|
||||
if canAccess, err = s.ruleAuthz.HasAccessInFolder(ctx, user, rule); err != nil {
|
||||
continue // Assume no access if there is an error but don't cache.
|
||||
}
|
||||
accessCacheByFolder[fp] = canAccess // Only cache if there is no error.
|
||||
}
|
||||
if !canAccess {
|
||||
continue
|
||||
}
|
||||
|
||||
if ruleSilences, ok := byRuleUID[rule.UID]; ok {
|
||||
for _, sil := range ruleSilences {
|
||||
if sil.Metadata.RuleMetadata == nil {
|
||||
sil.Metadata.RuleMetadata = &models.SilenceRuleMetadata{}
|
||||
}
|
||||
sil.Metadata.RuleMetadata.RuleTitle = rule.Title
|
||||
sil.Metadata.RuleMetadata.FolderUID = rule.NamespaceUID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
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...)
|
||||
}
|
||||
|
||||
if len(query.RuleUIDs) > 0 {
|
||||
args, in := getINSubQueryArgs(query.RuleUIDs)
|
||||
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
|
||||
}
|
||||
|
||||
if len(query.RuleGroups) > 0 {
|
||||
args, in := getINSubQueryArgs(query.RuleGroups)
|
||||
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...)
|
||||
|
@ -210,6 +210,9 @@ func (f *RuleStore) ListAlertRules(_ context.Context, q *models.ListAlertRulesQu
|
||||
if len(q.RuleGroups) > 0 && !slices.Contains(q.RuleGroups, r.RuleGroup) {
|
||||
continue
|
||||
}
|
||||
if len(q.RuleUIDs) > 0 && !slices.Contains(q.RuleUIDs, r.UID) {
|
||||
continue
|
||||
}
|
||||
|
||||
ruleList = append(ruleList, r)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user