diff --git a/pkg/services/ngalert/accesscontrol/fakes/rules.go b/pkg/services/ngalert/accesscontrol/fakes/rules.go index 9d6b5918921..75eab9cbc6a 100644 --- a/pkg/services/ngalert/accesscontrol/fakes/rules.go +++ b/pkg/services/ngalert/accesscontrol/fakes/rules.go @@ -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 { diff --git a/pkg/services/ngalert/accesscontrol/fakes/silences.go b/pkg/services/ngalert/accesscontrol/fakes/silences.go new file mode 100644 index 00000000000..0a14248e470 --- /dev/null +++ b/pkg/services/ngalert/accesscontrol/fakes/silences.go @@ -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 +} diff --git a/pkg/services/ngalert/accesscontrol/silences.go b/pkg/services/ngalert/accesscontrol/silences.go index aeefd3ee703..9d6eb83373f 100644 --- a/pkg/services/ngalert/accesscontrol/silences.go +++ b/pkg/services/ngalert/accesscontrol/silences.go @@ -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 +} diff --git a/pkg/services/ngalert/accesscontrol/silences_test.go b/pkg/services/ngalert/accesscontrol/silences_test.go index 78d38561fb3..3d87bc75688 100644 --- a/pkg/services/ngalert/accesscontrol/silences_test.go +++ b/pkg/services/ngalert/accesscontrol/silences_test.go @@ -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 { diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 9ab3a02da64..db3a8df6b61 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -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. diff --git a/pkg/services/ngalert/api/api_alertmanager_silences.go b/pkg/services/ngalert/api/api_alertmanager_silences.go index 8c4f92c5095..b59f3896119 100644 --- a/pkg/services/ngalert/api/api_alertmanager_silences.go +++ b/pkg/services/ngalert/api/api_alertmanager_silences.go @@ -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 +} diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index 2b1259af9b8..52def62fa93 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -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), } } diff --git a/pkg/services/ngalert/api/compat_silences.go b/pkg/services/ngalert/api/compat_silences.go index 77b5cff9dda..bef6b5e07de 100644 --- a/pkg/services/ngalert/api/compat_silences.go +++ b/pkg/services/ngalert/api/compat_silences.go @@ -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) + } +} diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 5248a2e5bbf..344f7e5cb34 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -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 diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index e44d1947611..e1590fef358 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -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 diff --git a/pkg/services/ngalert/models/silence.go b/pkg/services/ngalert/models/silence.go index fa7c391cb4e..f0b97609524 100644 --- a/pkg/services/ngalert/models/silence.go +++ b/pkg/services/ngalert/models/silence.go @@ -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] +} diff --git a/pkg/services/ngalert/models/silence_test.go b/pkg/services/ngalert/models/silence_test.go index a39881dd2ff..a4e43b96dea 100644 --- a/pkg/services/ngalert/models/silence_test.go +++ b/pkg/services/ngalert/models/silence_test.go @@ -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)) + } + }) + } + }) +} diff --git a/pkg/services/ngalert/notifier/silence_svc.go b/pkg/services/ngalert/notifier/silence_svc.go index da01516b1f1..f41b3a9674c 100644 --- a/pkg/services/ngalert/notifier/silence_svc.go +++ b/pkg/services/ngalert/notifier/silence_svc.go @@ -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 +} diff --git a/pkg/services/ngalert/notifier/silence_svc_test.go b/pkg/services/ngalert/notifier/silence_svc_test.go new file mode 100644 index 00000000000..96af8bfe2d6 --- /dev/null +++ b/pkg/services/ngalert/notifier/silence_svc_test.go @@ -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()) + }) +} diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index c233e001034..4bc1cdcf0cf 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -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...) diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index bf6ce2dcc46..3a54bf5e03f 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -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) }