diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index e7b402fe69a..dcabc17a9fb 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -3,6 +3,7 @@ package dashboards import ( "errors" + "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/util" ) @@ -129,6 +130,8 @@ var ( ErrFolderInvalidUID = errors.New("invalid uid for folder provided") ErrFolderSameNameExists = errors.New("a folder with the same name already exists in the current location") ErrFolderAccessDenied = errors.New("access denied to folder") + ErrMoveAccessDenied = errutil.Forbidden("folders.forbiddenMove", errutil.WithPublicMessage("Access denied to the destination folder")) + ErrFolderAccessEscalation = errutil.Forbidden("folders.accessEscalation", errutil.WithPublicMessage("Cannot move a folder to a folder where you have higher permissions")) ) // DashboardErr represents a dashboard error. diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 8b70af72be3..d3821095367 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -882,14 +882,7 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol } // Check that the user is allowed to move the folder to the destination folder - var evaluator accesscontrol.Evaluator - if cmd.NewParentUID != "" { - evaluator = accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.NewParentUID)) - } else { - // Evaluate folder creation permission when moving folder to the root level - evaluator = accesscontrol.EvalPermission(dashboards.ActionFoldersCreate) - } - hasAccess, evalErr := s.accessControl.Evaluate(ctx, cmd.SignedInUser, evaluator) + hasAccess, evalErr := s.canMove(ctx, cmd) if evalErr != nil { return nil, evalErr } @@ -955,6 +948,68 @@ func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*fol return f, nil } +func (s *Service) canMove(ctx context.Context, cmd *folder.MoveFolderCommand) (bool, error) { + // Check that the user is allowed to move the folder to the destination folder + var evaluator accesscontrol.Evaluator + parentUID := cmd.NewParentUID + if parentUID != "" { + evaluator = accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(parentUID)) + } else { + // Evaluate folder creation permission when moving folder to the root level + evaluator = accesscontrol.EvalPermission(dashboards.ActionFoldersCreate) + parentUID = folder.GeneralFolderUID + } + if hasAccess, err := s.accessControl.Evaluate(ctx, cmd.SignedInUser, evaluator); err != nil { + return false, err + } else if !hasAccess { + return false, dashboards.ErrMoveAccessDenied.Errorf("user does not have permissions to move a folder to folder with UID %s", parentUID) + } + + // Check that the user would not be elevating their permissions by moving a folder to the destination folder + // This is needed for plugins, as different folders can have different plugin configs + // We do this by checking that there are no permissions that user has on the destination parent folder but not on the source folder + // We also need to look at the folder tree for the destination folder, as folder permissions are inherited + newFolderAndParentUIDs, err := s.getFolderAndParentUIDScopes(ctx, parentUID, cmd.OrgID) + if err != nil { + return false, err + } + + permissions := cmd.SignedInUser.GetPermissions() + var evaluators []accesscontrol.Evaluator + currentFolderScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.UID) + for action, scopes := range permissions { + for _, scope := range newFolderAndParentUIDs { + if slices.Contains(scopes, scope) { + evaluators = append(evaluators, accesscontrol.EvalPermission(action, currentFolderScope)) + break + } + } + } + + if hasAccess, err := s.accessControl.Evaluate(ctx, cmd.SignedInUser, accesscontrol.EvalAll(evaluators...)); err != nil { + return false, err + } else if !hasAccess { + return false, dashboards.ErrFolderAccessEscalation.Errorf("user cannot move a folder to another folder where they have higher permissions") + } + return true, nil +} + +func (s *Service) getFolderAndParentUIDScopes(ctx context.Context, folderUID string, orgID int64) ([]string, error) { + folderAndParentUIDScopes := []string{dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)} + if folderUID == folder.GeneralFolderUID { + return folderAndParentUIDScopes, nil + } + folderParents, err := s.store.GetParents(ctx, folder.GetParentsQuery{UID: folderUID, OrgID: orgID}) + if err != nil { + return nil, err + } + for _, newParent := range folderParents { + scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(newParent.UID) + folderAndParentUIDScopes = append(folderAndParentUIDScopes, scope) + } + return folderAndParentUIDScopes, nil +} + // nestedFolderDelete inspects the folder referenced by the cmd argument, deletes all the entries for // its descendant folders (folders which are nested within it either directly or indirectly) from // the folder store and returns the UIDs for all its descendants. diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 391bfb4a1ae..ffd792a2f6c 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -41,6 +41,7 @@ import ( "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/ngalert/models" ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/store/entity" @@ -1144,7 +1145,7 @@ func TestNestedFolderService(t *testing.T) { features := featuremgmt.WithFeatures("nestedFolders") folderSvc := setup(t, dashStore, dashboardFolderStore, nestedFolderStore, features, acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()), dbtest.NewFakeDB()) _, err := folderSvc.Move(context.Background(), &folder.MoveFolderCommand{UID: "myFolder", NewParentUID: "newFolder", OrgID: orgID, SignedInUser: nestedFolderUser}) - require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) + require.ErrorIs(t, err, dashboards.ErrMoveAccessDenied) }) t.Run("move with the right permissions succeeds", func(t *testing.T) { @@ -1160,14 +1161,14 @@ func TestNestedFolderService(t *testing.T) { } nestedFolderUser := &user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{}} - nestedFolderUser.Permissions[orgID] = map[string][]string{dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("newFolder")}} + nestedFolderUser.Permissions[orgID] = map[string][]string{ + dashboards.ActionFoldersWrite: {dashboards.ScopeFoldersProvider.GetResourceScopeUID("myFolder"), dashboards.ScopeFoldersProvider.GetResourceScopeUID("newFolder")}, + } features := featuremgmt.WithFeatures("nestedFolders") folderSvc := setup(t, dashStore, dashboardFolderStore, nestedFolderStore, features, acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()), dbtest.NewFakeDB()) _, err := folderSvc.Move(context.Background(), &folder.MoveFolderCommand{UID: "myFolder", NewParentUID: "newFolder", OrgID: orgID, SignedInUser: nestedFolderUser}) require.NoError(t, err) - // the folder is set inside InTransaction() but the fake one is called - // require.NotNil(t, f) }) t.Run("cannot move the k6 folder even when has permissions to move folders", func(t *testing.T) { @@ -2190,6 +2191,161 @@ func TestGetChildrenFilterByPermission(t *testing.T) { } } +func TestIntegration_canMove(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dashStore := &dashboards.FakeDashboardStore{} + dashboardFolderStore := foldertest.NewFakeFolderStore(t) + + db, cfg := sqlstore.InitTestDB(t) + folderStore := ProvideStore(db) + orgID := CreateOrg(t, db, cfg) + + adminUsr := &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleAdmin} + + // Set up source folder and a source folder parent + sourceParent, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + UID: "source-parent", + OrgID: orgID, + Title: "Source parent", + SignedInUser: adminUsr, + }) + require.NoError(t, err) + sourceFolder, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + UID: "source", + OrgID: orgID, + Title: "Source", + ParentUID: sourceParent.UID, + SignedInUser: adminUsr, + }) + require.NoError(t, err) + + // Set up destination folder and destination folder parent + destParent, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + UID: "destination-parent", + OrgID: orgID, + Title: "Destination parent", + SignedInUser: adminUsr, + }) + require.NoError(t, err) + destFolder, err := folderStore.Create(context.Background(), folder.CreateFolderCommand{ + UID: "destination", + OrgID: orgID, + Title: "Destination", + ParentUID: destParent.UID, + SignedInUser: adminUsr, + }) + require.NoError(t, err) + + features := featuremgmt.WithFeatures("nestedFolders") + folderSvc := setup(t, dashStore, dashboardFolderStore, folderStore, features, acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()), dbtest.NewFakeDB()) + + testCases := []struct { + description string + destinationFolder string + permissions map[string][]string + expectedErr error + }{ + { + description: "can move a folder if has edit access to both folders", + destinationFolder: destFolder.UID, + permissions: map[string][]string{ + dashboards.ActionFoldersWrite: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(sourceFolder.UID), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(destFolder.UID), + }, + }, + }, + { + description: "can't move a folder if missing write access to the destination folder", + destinationFolder: destFolder.UID, + permissions: map[string][]string{ + dashboards.ActionFoldersWrite: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(sourceFolder.UID), + }, + dashboards.ActionFoldersRead: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(destFolder.UID), + }, + }, + expectedErr: dashboards.ErrMoveAccessDenied, + }, + { + description: "can't move a folder to the root if missing folder create permissions", + destinationFolder: "", + permissions: map[string][]string{ + dashboards.ActionFoldersWrite: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(sourceFolder.UID), + }, + }, + expectedErr: dashboards.ErrMoveAccessDenied, + }, + { + description: "can move a folder to the root with folder create permissions", + destinationFolder: "", + permissions: map[string][]string{ + dashboards.ActionFoldersCreate: {}, + }, + }, + { + description: "can't move a folder to another folder where user has higher plugin permissions", + destinationFolder: destFolder.UID, + permissions: map[string][]string{ + dashboards.ActionFoldersWrite: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(sourceFolder.UID), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(destFolder.UID), + }, + "some_plugin:action": { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(destFolder.UID), + }, + }, + expectedErr: dashboards.ErrFolderAccessEscalation, + }, + { + description: "can move a folder to another folder where user has lower permissions", + destinationFolder: destFolder.UID, + permissions: map[string][]string{ + dashboards.ActionFoldersWrite: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(sourceFolder.UID), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(destFolder.UID), + }, + "some_plugin:action": { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(sourceFolder.UID), + }, + }, + }, + { + description: "can't move a folder to another folder where user has higher plugin permissions through inheritance", + destinationFolder: destFolder.UID, + permissions: map[string][]string{ + dashboards.ActionFoldersWrite: { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(sourceFolder.UID), + dashboards.ScopeFoldersProvider.GetResourceScopeUID(destFolder.UID), + }, + "some_plugin:action": { + dashboards.ScopeFoldersProvider.GetResourceScopeUID(destParent.UID), + }, + }, + expectedErr: dashboards.ErrFolderAccessEscalation, + }, + } + + for _, tc := range testCases { + usr := &user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{}} + usr.Permissions[orgID] = tc.permissions + + t.Run(tc.description, func(t *testing.T) { + _, err := folderSvc.Move(context.Background(), &folder.MoveFolderCommand{UID: sourceFolder.UID, NewParentUID: tc.destinationFolder, OrgID: orgID, SignedInUser: usr}) + if tc.expectedErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.expectedErr) + } + }) + } +} + func TestSupportBundle(t *testing.T) { f := func(uid, parent string) *folder.Folder { return &folder.Folder{UID: uid, ParentUID: parent} } for _, tc := range []struct {