diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 5e614226f3e..3ee940ded3d 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -155,6 +155,8 @@ type ServiceAccountPermissionsService interface { type ReceiverPermissionsService interface { PermissionsService + SetDefaultPermissions(ctx context.Context, orgID int64, user identity.Requester, uid string) + CopyPermissions(ctx context.Context, orgID int64, user identity.Requester, oldUID, newUID string) (int, error) } type PermissionsService interface { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/receivers.go b/pkg/services/accesscontrol/ossaccesscontrol/receivers.go index 8ef3bb837b1..f7ef1bc3edc 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/receivers.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/receivers.go @@ -1,14 +1,22 @@ package ossaccesscontrol import ( + "context" + + "github.com/grafana/authlib/claims" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/ngalert" alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -18,6 +26,14 @@ var ReceiversViewActions = []string{accesscontrol.ActionAlertingReceiversRead} var ReceiversEditActions = append(ReceiversViewActions, []string{accesscontrol.ActionAlertingReceiversUpdate, accesscontrol.ActionAlertingReceiversDelete}...) var ReceiversAdminActions = append(ReceiversEditActions, []string{accesscontrol.ActionAlertingReceiversReadSecrets, accesscontrol.ActionAlertingReceiversPermissionsRead, accesscontrol.ActionAlertingReceiversPermissionsWrite}...) +// defaultPermissions returns the default permissions for a newly created receiver. +func defaultPermissions() []accesscontrol.SetResourcePermissionCommand { + return []accesscontrol.SetResourcePermissionCommand{ + {BuiltinRole: string(org.RoleEditor), Permission: string(alertingac.ReceiverPermissionEdit)}, + {BuiltinRole: string(org.RoleViewer), Permission: string(alertingac.ReceiverPermissionView)}, + } +} + func ProvideReceiverPermissionsService( cfg *setting.Cfg, features featuremgmt.FeatureToggles, router routing.RouteRegister, sql db.DB, ac accesscontrol.AccessControl, license licensing.Licensing, service accesscontrol.Service, @@ -50,11 +66,131 @@ func ProvideReceiverPermissionsService( if err != nil { return nil, err } - return &ReceiverPermissionsService{Service: srv}, nil + return &ReceiverPermissionsService{srv, service, log.New("resourcepermissions.receivers")}, nil } var _ accesscontrol.ReceiverPermissionsService = new(ReceiverPermissionsService) type ReceiverPermissionsService struct { *resourcepermissions.Service + ac accesscontrol.Service + log log.Logger +} + +// SetDefaultPermissions sets the default permissions for a newly created receiver. +func (r ReceiverPermissionsService) SetDefaultPermissions(ctx context.Context, orgID int64, user identity.Requester, uid string) { + permissions := defaultPermissions() + clearCache := false + if user != nil && user.IsIdentityType(claims.TypeUser) { + userID, err := user.GetInternalID() + if err != nil { + r.log.Error("Could not make user admin", "receiver_uid", uid, "id", user.GetID(), "error", err) + } else { + permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{ + UserID: userID, Permission: string(alertingac.ReceiverPermissionAdmin), + }) + clearCache = true + } + } + + if _, err := r.SetPermissions(ctx, orgID, uid, permissions...); err != nil { + r.log.Error("Could not set default permissions", "receiver_uid", uid, "error", err) + } + + if clearCache { + // Clear permission cache for the user who created the receiver, so that new permissions are fetched for their next call + // Required for cases when caller wants to immediately interact with the newly created object + r.ac.ClearUserPermissionCache(user) + } +} + +// copyPermissionUser returns a user with permissions to copy permissions from one receiver to another. This must include +// permissions to read and write permissions for the receiver, as well as read permissions for users, service accounts, and teams. +func copyPermissionUser(orgID int64) identity.Requester { + return accesscontrol.BackgroundUser("receiver_access_service", orgID, org.RoleAdmin, accesscontrol.ConcatPermissions( + accesscontrol.PermissionsForActions(ReceiversAdminActions, alertingac.ScopeReceiversAll), + []accesscontrol.Permission{ // Permissions needed for GetPermissions to return user, service account, and team permissions. + {Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll}, + {Action: serviceaccounts.ActionRead, Scope: serviceaccounts.ScopeAll}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + }, + ), + ) +} + +// CopyPermissions copies the resource permissions from one receiver uid to a new uid. This is a temporary +// method to be used during receiver renaming that is necessitated by receiver uids being generated from the receiver +// name. +func (r ReceiverPermissionsService) CopyPermissions(ctx context.Context, orgID int64, user identity.Requester, oldUID, newUID string) (int, error) { + currentPermissions, err := r.GetPermissions(ctx, copyPermissionUser(orgID), oldUID) + if err != nil { + return 0, err + } + + setPermissionCommands := r.toSetResourcePermissionCommands(currentPermissions) + if _, err := r.SetPermissions(ctx, orgID, newUID, setPermissionCommands...); err != nil { + return 0, err + } + + // Clear permission cache for the user who updated the receiver, so that new permissions are fetched for their next call + // Required for cases when caller wants to immediately interact with the newly updated object + if user != nil && user.IsIdentityType(claims.TypeUser) { + r.ac.ClearUserPermissionCache(user) + } + + return countCustomPermissions(setPermissionCommands), nil +} + +// toSetResourcePermissionCommands converts a list of resource permissions to a list of set resource permission commands. +// Only includes managed permissions. +func (r ReceiverPermissionsService) toSetResourcePermissionCommands(permissions []accesscontrol.ResourcePermission) []accesscontrol.SetResourcePermissionCommand { + cmds := make([]accesscontrol.SetResourcePermissionCommand, 0, len(permissions)) + for _, p := range permissions { + if !p.IsManaged { + continue + } + permission := r.MapActions(p) + if permission == "" { + continue + } + cmds = append(cmds, accesscontrol.SetResourcePermissionCommand{ + Permission: permission, + BuiltinRole: p.BuiltInRole, + TeamID: p.TeamId, + UserID: p.UserId, + }) + } + return cmds +} + +// countCustomPermissions counts the number of custom permissions in a list of set resource permission commands. A +// custom permission is a permission that is not a default permission for a receiver. +func countCustomPermissions(permissions []accesscontrol.SetResourcePermissionCommand) int { + cacheKey := func(p accesscontrol.SetResourcePermissionCommand) accesscontrol.SetResourcePermissionCommand { + return accesscontrol.SetResourcePermissionCommand{ + Permission: "", + BuiltinRole: p.BuiltinRole, + TeamID: p.TeamID, + UserID: p.UserID, + } + } + missingDefaults := make(map[accesscontrol.SetResourcePermissionCommand]string, 2) + for _, p := range defaultPermissions() { + missingDefaults[cacheKey(p)] = p.Permission + } + + diff := 0 + for _, p := range permissions { + key := cacheKey(p) + perm, ok := missingDefaults[key] + if perm != p.Permission { + diff++ + } + if ok { + delete(missingDefaults, key) + } + } + + // missing + new + return len(missingDefaults) + diff } diff --git a/pkg/services/ngalert/api/api_notifications_test.go b/pkg/services/ngalert/api/api_notifications_test.go index b9ef77ecf42..fc006e37c39 100644 --- a/pkg/services/ngalert/api/api_notifications_test.go +++ b/pkg/services/ngalert/api/api_notifications_test.go @@ -374,6 +374,7 @@ func createNotificationSrvSutFromEnv(t *testing.T, env *testEnvironment) Notific env.secrets, env.xact, env.log, + fakes.NewFakeReceiverPermissionsService(), ) return NotificationSrv{ logger: env.log, diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 8c6cf2ccc60..7832ef59a30 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -43,6 +43,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" + ngalertfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/secrets" secrets_fakes "github.com/grafana/grafana/pkg/services/secrets/fakes" @@ -1901,6 +1902,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi env.secrets, env.xact, env.log, + ngalertfakes.NewFakeReceiverPermissionsService(), ) return ProvisioningSrv{ log: env.log, diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index ed4ad7df2a2..75f38395233 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -77,6 +77,7 @@ func ProvideService( tracer tracing.Tracer, ruleStore *store.DBstore, httpClientProvider httpclient.Provider, + resourcePermissions accesscontrol.ReceiverPermissionsService, ) (*AlertNG, error) { ng := &AlertNG{ Cfg: cfg, @@ -98,12 +99,13 @@ func ProvideService( dashboardService: dashboardService, renderService: renderService, bus: bus, - accesscontrolService: accesscontrolService, + AccesscontrolService: accesscontrolService, annotationsRepo: annotationsRepo, pluginsStore: pluginsStore, tracer: tracer, store: ruleStore, httpClientProvider: httpClientProvider, + ResourcePermissions: resourcePermissions, } if ng.IsDisabled() { @@ -147,7 +149,8 @@ type AlertNG struct { MultiOrgAlertmanager *notifier.MultiOrgAlertmanager AlertsRouter *sender.AlertsRouter accesscontrol accesscontrol.AccessControl - accesscontrolService accesscontrol.Service + AccesscontrolService accesscontrol.Service + ResourcePermissions accesscontrol.ReceiverPermissionsService annotationsRepo annotations.Repository store *store.DBstore @@ -423,6 +426,7 @@ func (ng *AlertNG) init() error { ng.SecretsService, ng.store, ng.Log, + ng.ResourcePermissions, ) provisioningReceiverService := notifier.NewReceiverService( ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true), @@ -432,6 +436,7 @@ func (ng *AlertNG) init() error { ng.SecretsService, ng.store, ng.Log, + ng.ResourcePermissions, ) // Provisioning @@ -489,7 +494,7 @@ func (ng *AlertNG) init() error { return key.LogContext(), true }) - return DeclareFixedRoles(ng.accesscontrolService, ng.FeatureToggles) + return DeclareFixedRoles(ng.AccesscontrolService, ng.FeatureToggles) } func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleStore) { diff --git a/pkg/services/ngalert/notifier/legacy_storage/receivers.go b/pkg/services/ngalert/notifier/legacy_storage/receivers.go index 158575401ba..cd5cf550735 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/receivers.go +++ b/pkg/services/ngalert/notifier/legacy_storage/receivers.go @@ -19,7 +19,7 @@ func (rev *ConfigRevision) DeleteReceiver(uid string) { func (rev *ConfigRevision) CreateReceiver(receiver *models.Receiver) (*definitions.PostableApiReceiver, error) { // Check if the receiver already exists. - _, err := rev.GetReceiver(NameToUid(receiver.Name)) // get UID from name because the new receiver does not have UID yet. + _, err := rev.GetReceiver(receiver.GetUID()) if err == nil { return nil, ErrReceiverExists.Errorf("") } diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index e3b83d71e42..2e2bdd99ba8 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" @@ -45,6 +46,7 @@ type ReceiverService struct { xact transactionManager log log.Logger provenanceValidator validation.ProvenanceStatusTransitionValidator + resourcePermissions ac.ReceiverPermissionsService } type alertRuleNotificationSettingsStore interface { @@ -96,6 +98,7 @@ func NewReceiverService( encryptionService secretService, xact transactionManager, log log.Logger, + resourcePermissions ac.ReceiverPermissionsService, ) *ReceiverService { return &ReceiverService{ authz: authz, @@ -106,6 +109,7 @@ func NewReceiverService( xact: xact, log: log, provenanceValidator: validation.ValidateProvenanceRelaxed, + resourcePermissions: resourcePermissions, } } @@ -312,6 +316,12 @@ func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, calle if err != nil { return err } + if rs.resourcePermissions != nil { + err = rs.resourcePermissions.DeleteResourcePermissions(ctx, orgID, uid) + if err != nil { + rs.log.Error("Could not delete receiver permissions", "receiver", existing.Name, "error", err) + } + } return rs.deleteProvenances(ctx, orgID, existing.Integrations) }) } @@ -336,6 +346,9 @@ func (rs *ReceiverService) CreateReceiver(ctx context.Context, r *models.Receive return nil, legacy_storage.MakeErrReceiverInvalid(err) } + // Generate UID from name. + createdReceiver.UID = legacy_storage.NameToUid(createdReceiver.Name) + created, err := revision.CreateReceiver(&createdReceiver) if err != nil { return nil, err @@ -346,6 +359,9 @@ func (rs *ReceiverService) CreateReceiver(ctx context.Context, r *models.Receive if err != nil { return err } + if rs.resourcePermissions != nil { + rs.resourcePermissions.SetDefaultPermissions(ctx, orgID, user, createdReceiver.GetUID()) + } return rs.setReceiverProvenance(ctx, orgID, &createdReceiver) }) if err != nil { @@ -422,6 +438,19 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive if err != nil { return err } + // Update receiver permissions + if rs.resourcePermissions != nil { + permissionsUpdated, err := rs.resourcePermissions.CopyPermissions(ctx, orgID, user, legacy_storage.NameToUid(existing.Name), legacy_storage.NameToUid(r.Name)) + if err != nil { + return err + } + if permissionsUpdated > 0 { + rs.log.FromContext(ctx).Debug("Moved custom receiver permissions", "oldName", existing.Name, "newName", r.Name, "count", permissionsUpdated) + } + if err := rs.resourcePermissions.DeleteResourcePermissions(ctx, orgID, legacy_storage.NameToUid(existing.Name)); err != nil { + return err + } + } } err = rs.cfgStore.Save(ctx, revision, orgID) if err != nil { diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index e8b168a36ee..b96f5594ad9 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -1483,6 +1483,7 @@ func createReceiverServiceSut(t *testing.T, encryptSvc secretService) *ReceiverS encryptSvc, xact, log.NewNopLogger(), + fakes.NewFakeReceiverPermissionsService(), ) } diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index 8473bece839..879a1558b28 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -493,6 +493,7 @@ func createContactPointServiceSutWithConfigStore(t *testing.T, secretService sec secretService, xact, log.NewNopLogger(), + fakes.NewFakeReceiverPermissionsService(), ) return &ContactPointService{ diff --git a/pkg/services/ngalert/tests/fakes/permissions.go b/pkg/services/ngalert/tests/fakes/permissions.go new file mode 100644 index 00000000000..b806c49cd3a --- /dev/null +++ b/pkg/services/ngalert/tests/fakes/permissions.go @@ -0,0 +1,28 @@ +package fakes + +import ( + "context" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" +) + +type FakeReceiverPermissionsService struct { + *actest.FakePermissionsService +} + +func NewFakeReceiverPermissionsService() *FakeReceiverPermissionsService { + return &FakeReceiverPermissionsService{ + FakePermissionsService: &actest.FakePermissionsService{}, + } +} + +func (f FakeReceiverPermissionsService) SetDefaultPermissions(ctx context.Context, orgID int64, user identity.Requester, uid string) { +} + +func (f FakeReceiverPermissionsService) CopyPermissions(ctx context.Context, orgID int64, user identity.Requester, oldUID, newUID string) (int, error) { + return 0, nil +} + +var _ accesscontrol.ReceiverPermissionsService = new(FakeReceiverPermissionsService) diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index eb2f74befe4..4b635292ba5 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" + ngalertfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/ngalert/testutil" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -70,7 +71,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, ng, err := ngalert.ProvideService( cfg, features, nil, nil, routing.NewRouteRegister(), sqlStore, kvstore.NewFakeKVStore(), nil, nil, quotatest.New(false, nil), secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac, - annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), + annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(), ) require.NoError(tb, err) return ng, &store.DBstore{ diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 86330a869ff..14548a576f2 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -53,6 +53,7 @@ func ProvideService( quotaService quota.Service, secrectService secrets.Service, orgService org.Service, + resourcePermissions accesscontrol.ReceiverPermissionsService, ) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, @@ -76,6 +77,7 @@ func ProvideService( log: log.New("provisioning"), orgService: orgService, folderService: folderService, + resourcePermissions: resourcePermissions, } if err := s.setDashboardProvisioner(); err != nil { @@ -154,6 +156,7 @@ type ProvisioningServiceImpl struct { quotaService quota.Service secretService secrets.Service folderService folder.Service + resourcePermissions accesscontrol.ReceiverPermissionsService } func (ps *ProvisioningServiceImpl) RunInitProvisioners(ctx context.Context) error { @@ -287,6 +290,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error ps.secretService, ps.SQLStore, ps.log, + ps.resourcePermissions, ) contactPointService := provisioning.NewContactPointService(configStore, ps.secretService, st, ps.SQLStore, receiverSvc, ps.log, &st) diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 6b07f83d0f3..4c334396003 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -502,7 +502,7 @@ func setupEnv(t *testing.T, replStore db.ReplDB, cfg *setting.Cfg, b bus.Bus, qu _, err = ngalert.ProvideService( cfg, featuremgmt.WithFeatures(), nil, nil, routing.NewRouteRegister(), sqlStore, ngalertfakes.NewFakeKVStore(t), nil, nil, quotaService, secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{}, - annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), + annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(), ) require.NoError(t, err) _, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), cfg, quotaService, storesrv.ProvideSystemUsersService()) diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index ebbacc56005..1e13f335719 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -273,6 +274,42 @@ func (a apiClient) ReloadCachedPermissions(t *testing.T) { require.Equalf(t, http.StatusOK, resp.StatusCode, "failed to reload permissions cache") } +// AssignReceiverPermission sends a request to access control API to assign permissions to a user, role, or team on a receiver. +func (a apiClient) AssignReceiverPermission(t *testing.T, receiverUID string, cmd accesscontrol.SetResourcePermissionCommand) (int, string) { + t.Helper() + + var assignment string + var assignTo string + if cmd.UserID != 0 { + assignment = "users" + assignTo = fmt.Sprintf("%d", cmd.UserID) + } else if cmd.TeamID != 0 { + assignment = "teams" + assignTo = fmt.Sprintf("%d", cmd.TeamID) + } else { + assignment = "builtInRoles" + assignTo = cmd.BuiltinRole + } + + body := strings.NewReader(fmt.Sprintf(`{"permission": "%s"}`, cmd.Permission)) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/access-control/receivers/%s/%s/%s", a.url, receiverUID, assignment, assignTo), body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + + defer func() { + _ = resp.Body.Close() + }() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(b) +} + // CreateFolder creates a folder for storing our alerts, and then refreshes the permission cache to make sure that following requests will be accepted func (a apiClient) CreateFolder(t *testing.T, uID string, title string, parentUID ...string) { t.Helper() diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go index 4017519158f..3e65c662567 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go @@ -33,6 +33,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder/foldertest" + alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/api" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -126,6 +127,289 @@ func TestIntegrationResourceIdentifier(t *testing.T) { }) } +// TestIntegrationResourcePermissions focuses on testing resource permissions for the alerting receiver resource. It +// verifies that access is correctly set when creating resources and assigning permissions to users, teams, and roles. +func TestIntegrationResourcePermissions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + org1 := helper.Org1 + + noneUser := helper.CreateUser("none", apis.Org1, org.RoleNone, nil) + + creator := helper.CreateUser("creator", apis.Org1, org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + createWildcardPermission( + accesscontrol.ActionAlertingReceiversCreate, + ), + }) + + newClient := func(t *testing.T, user apis.User) notificationsv0alpha1.ReceiverInterface { + k8sClient, err := versioned.NewForConfig(user.NewRestConfig()) + require.NoError(t, err) + return k8sClient.NotificationsV0alpha1().Receivers("default") + } + + admin := org1.Admin + viewer := org1.Viewer + editor := org1.Editor + adminClient := newClient(t, admin) + + writeACMetadata := []string{"canWrite", "canDelete"} + allACMetadata := []string{"canWrite", "canDelete", "canReadSecrets", "canAdmin"} + + mustID := func(user apis.User) int64 { + id, err := user.Identity.GetInternalID() + require.NoError(t, err) + return id + } + + for _, tc := range []struct { + name string + creatingUser apis.User + testUser apis.User + assignments []accesscontrol.SetResourcePermissionCommand + expACMetadata []string + expRead bool + }{ + // Basic access. + { + name: "Admin creates and has all metadata and access", + creatingUser: admin, + testUser: admin, + assignments: nil, + expACMetadata: allACMetadata, + expRead: true, + }, + { + name: "Creator creates and has all metadata and access", + creatingUser: creator, + testUser: creator, + assignments: nil, + expACMetadata: allACMetadata, + expRead: true, + }, + { + name: "Admin creates, noneUser has no metadata and no access", + creatingUser: admin, + testUser: noneUser, + assignments: nil, + expACMetadata: nil, + expRead: false, + }, + { + name: "Admin creates, viewer has no metadata but has access", + creatingUser: admin, + testUser: viewer, + expACMetadata: nil, + expRead: true, + }, + { + name: "Admin creates, editor has write metadata and access", + creatingUser: admin, + testUser: editor, + expACMetadata: writeACMetadata, + expRead: true, + }, + // User-based assignments. + { + name: "Admin creates, assigns read, noneUser has no metadata but has access", + creatingUser: admin, + testUser: noneUser, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(noneUser), Permission: string(alertingac.ReceiverPermissionView)}}, + expACMetadata: nil, + expRead: true, + }, + { + name: "Admin creates, assigns write, noneUser has write metadata and access", + creatingUser: admin, + testUser: noneUser, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(noneUser), Permission: string(alertingac.ReceiverPermissionEdit)}}, + expACMetadata: writeACMetadata, + expRead: true, + }, + { + name: "Admin creates, assigns admin, noneUser has all metadata and access", + creatingUser: admin, + testUser: noneUser, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(noneUser), Permission: string(alertingac.ReceiverPermissionAdmin)}}, + expACMetadata: allACMetadata, + expRead: true, + }, + // Other users don't get assignments. + { + name: "Admin creates, assigns read to noneUser, creator has no metadata and no access", + creatingUser: admin, + testUser: creator, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(noneUser), Permission: string(alertingac.ReceiverPermissionView)}}, + expACMetadata: nil, + expRead: false, + }, + { + name: "Admin creates, assigns write to noneUser, creator has no metadata and no access", + creatingUser: admin, + testUser: creator, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(noneUser), Permission: string(alertingac.ReceiverPermissionEdit)}}, + expACMetadata: nil, + expRead: false, + }, + { + name: "Admin creates, assigns admin to noneUser, creator has no metadata and no access", + creatingUser: admin, + testUser: creator, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(noneUser), Permission: string(alertingac.ReceiverPermissionAdmin)}}, + expACMetadata: nil, + expRead: false, + }, + // Role-based access. + { + name: "Admin creates, assigns editor, viewer has write metadata and access", + creatingUser: admin, + testUser: viewer, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(viewer), Permission: string(alertingac.ReceiverPermissionEdit)}}, + expACMetadata: writeACMetadata, + expRead: true, + }, + { + name: "Admin creates, assigns admin, viewer has all metadata and access", + creatingUser: admin, + testUser: viewer, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(viewer), Permission: string(alertingac.ReceiverPermissionAdmin)}}, + expACMetadata: allACMetadata, + expRead: true, + }, + { + name: "Admin creates, assigns admin, editor has all metadata and access", + creatingUser: admin, + testUser: editor, + assignments: []accesscontrol.SetResourcePermissionCommand{{UserID: mustID(editor), Permission: string(alertingac.ReceiverPermissionAdmin)}}, + expACMetadata: allACMetadata, + expRead: true, + }, + // Team-based access. Staff team has editor+admin but not viewer in it. + { + name: "Admin creates, assigns admin to staff, viewer has no metadata and access", + creatingUser: admin, + testUser: viewer, + assignments: []accesscontrol.SetResourcePermissionCommand{{TeamID: org1.Staff.ID, Permission: string(alertingac.ReceiverPermissionAdmin)}}, + expACMetadata: nil, + expRead: true, + }, + { + name: "Admin creates, assigns admin to staff, editor has all metadata and access", + creatingUser: admin, + testUser: editor, + assignments: []accesscontrol.SetResourcePermissionCommand{{TeamID: org1.Staff.ID, Permission: string(alertingac.ReceiverPermissionAdmin)}}, + expACMetadata: allACMetadata, + expRead: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + createClient := newClient(t, tc.creatingUser) + client := newClient(t, tc.testUser) + + var created = &v0alpha1.Receiver{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.ReceiverSpec{ + Title: "receiver-1", + Integrations: nil, + }, + } + d, err := json.Marshal(created) + require.NoError(t, err) + + // Create receiver with creatingUser + created, err = createClient.Create(ctx, created, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.NotNil(t, created) + + defer func() { + _ = adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + }() + + // Assign resource permissions + cliCfg := helper.Org1.Admin.NewRestConfig() + alertingApi := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password) + for _, permission := range tc.assignments { + status, body := alertingApi.AssignReceiverPermission(t, created.Name, permission) + require.Equalf(t, http.StatusOK, status, "Expected status 200 but got %d: %s", status, body) + } + + // Test read + if tc.expRead { + // Helper methods. + extractReceiverFromList := func(list *v0alpha1.ReceiverList, name string) *v0alpha1.Receiver { + for i := range list.Items { + if list.Items[i].Name == name { + return list.Items[i].DeepCopy() + } + } + return nil + } + + // Obtain expected responses using admin client as source of truth. + expectedGetWithMetadata, expectedListWithMetadata := func() (*v0alpha1.Receiver, *v0alpha1.Receiver) { + expectedGet, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, expectedGet) + + // Set expected metadata. + expectedGetWithMetadata := expectedGet.DeepCopy() + // Clear any existing access control metadata. + for _, k := range allACMetadata { + delete(expectedGetWithMetadata.Annotations, v0alpha1.AccessControlAnnotation(k)) + } + for _, ac := range tc.expACMetadata { + expectedGetWithMetadata.SetAccessControl(ac) + } + + expectedList, err := adminClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + expectedListWithMetadata := extractReceiverFromList(expectedList, created.Name) + require.NotNil(t, expectedListWithMetadata) + expectedListWithMetadata = expectedListWithMetadata.DeepCopy() + // Clear any existing access control metadata. + for _, k := range allACMetadata { + delete(expectedListWithMetadata.Annotations, v0alpha1.AccessControlAnnotation(k)) + } + for _, ac := range tc.expACMetadata { + expectedListWithMetadata.SetAccessControl(ac) + } + return expectedGetWithMetadata, expectedListWithMetadata + }() + + t.Run("should be able to list receivers", func(t *testing.T) { + list, err := client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + listedReceiver := extractReceiverFromList(list, created.Name) + assert.Equalf(t, expectedListWithMetadata, listedReceiver, "Expected %v but got %v", expectedListWithMetadata, listedReceiver) + }) + + t.Run("should be able to read receiver by resource identifier", func(t *testing.T) { + got, err := client.Get(ctx, expectedGetWithMetadata.Name, v1.GetOptions{}) + require.NoError(t, err) + assert.Equalf(t, expectedGetWithMetadata, got, "Expected %v but got %v", expectedGetWithMetadata, got) + }) + } else { + t.Run("should be forbidden to list receivers", func(t *testing.T) { + _, err := client.List(ctx, v1.ListOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + + t.Run("should be forbidden to read receiver by name", func(t *testing.T) { + _, err := client.Get(ctx, created.Name, v1.GetOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + } + }) + } +} + func TestIntegrationAccessControl(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -293,34 +577,36 @@ func TestIntegrationAccessControl(t *testing.T) { d, err := json.Marshal(expected) require.NoError(t, err) + newReceiver := expected.DeepCopy() + newReceiver.Spec.Title = fmt.Sprintf("receiver-2-%s", tc.user.Identity.GetLogin()) if tc.canCreate { t.Run("should be able to create receiver", func(t *testing.T) { - newReceiver := expected - actual, err := client.Create(ctx, newReceiver, v1.CreateOptions{}) require.NoErrorf(t, err, "Payload %s", string(d)) - require.Equal(t, expected.Spec, actual.Spec) + require.Equal(t, newReceiver.Spec, actual.Spec) t.Run("should fail if already exists", func(t *testing.T) { _, err := client.Create(ctx, newReceiver, v1.CreateOptions{}) require.Truef(t, errors.IsConflict(err), "expected bad request but got %s", err) }) - expected = actual + // Cleanup. + require.NoError(t, adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{})) }) } else { t.Run("should be forbidden to create", func(t *testing.T) { - _, err := client.Create(ctx, expected, v1.CreateOptions{}) + _, err := client.Create(ctx, newReceiver, v1.CreateOptions{}) require.Truef(t, errors.IsForbidden(err), "Payload %s", string(d)) }) - - // create resource to proceed with other tests - expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) - require.NoErrorf(t, err, "Payload %s", string(d)) - require.NotNil(t, expected) } + // create resource to proceed with other tests. We don't use the one created above because the user will always + // have admin permissions on it. + expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.NotNil(t, expected) + if tc.canRead { // Set expected metadata. expectedWithMetadata := expected.DeepCopy() diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 98c7e634929..e9debc2e91d 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "strconv" "testing" "time" @@ -29,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/datasources" @@ -434,7 +436,11 @@ func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers { Viewer: c.CreateUser("viewer", orgName, org.RoleViewer, nil), } users.Staff = c.CreateTeam("staff", "staff@"+orgName, users.Admin.Identity.GetOrgID()) - // TODO add admin and editor to staff + + // Add Admin and Editor to Staff team as Admin and Member, respectively. + c.AddOrUpdateTeamMember(users.Admin, users.Staff.ID, team.PermissionTypeAdmin) + c.AddOrUpdateTeamMember(users.Editor, users.Staff.ID, team.PermissionTypeMember) + return users } @@ -532,6 +538,42 @@ func (c *K8sTestHelper) SetPermissions(user User, permissions []resourcepermissi } } +func (c *K8sTestHelper) AddOrUpdateTeamMember(user User, teamID int64, permission team.PermissionType) { + teamSvc, err := teamimpl.ProvideService(c.env.ReadReplStore, c.env.Cfg, tracing.InitializeTracerForTest()) + require.NoError(c.t, err) + + orgService, err := orgimpl.ProvideService(c.env.SQLStore, c.env.Cfg, c.env.Server.HTTPServer.QuotaService) + require.NoError(c.t, err) + + cache := localcache.ProvideService() + userSvc, err := userimpl.ProvideService( + c.env.SQLStore, orgService, c.env.Cfg, teamSvc, + cache, tracing.InitializeTracerForTest(), c.env.Server.HTTPServer.QuotaService, + supportbundlestest.NewFakeBundleService()) + require.NoError(c.t, err) + + teampermissionSvc, err := ossaccesscontrol.ProvideTeamPermissions( + c.env.Cfg, + c.env.FeatureToggles, + c.env.Server.HTTPServer.RouteRegister, + c.env.SQLStore, + c.env.Server.HTTPServer.AccessControl, + c.env.Server.HTTPServer.License, + c.env.Server.HTTPServer.AlertNG.AccesscontrolService, + teamSvc, + userSvc, + resourcepermissions.NewActionSetService(c.env.FeatureToggles), + ) + require.NoError(c.t, err) + + id, err := user.Identity.GetInternalID() + require.NoError(c.t, err) + + teamIDString := strconv.FormatInt(teamID, 10) + _, err = teampermissionSvc.SetUserPermission(context.Background(), user.Identity.GetOrgID(), accesscontrol.User{ID: id}, teamIDString, permission.String()) + require.NoError(c.t, err) +} + func (c *K8sTestHelper) NewDiscoveryClient() *discovery.DiscoveryClient { c.t.Helper()