diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index a9d434a91a8..f9267ad1eb5 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -173,6 +173,7 @@ Experimental features might be changed or removed without prior notice. | `kubernetesCliDashboards` | Use the k8s client to retrieve dashboards internally | | `kubernetesRestore` | Allow restoring objects in k8s | | `kubernetesFolders` | Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s | +| `kubernetesFoldersServiceV2` | Use the Folders Service V2, and route Folder Service requests to k8s | | `grafanaAPIServerTestingWithExperimentalAPIs` | Facilitate integration testing of experimental APIs | | `datasourceQueryTypes` | Show query type endpoints in datasource API servers (currently hardcoded for testdata, expressions, and prometheus) | | `queryService` | Register /apis/query.grafana.app/ -- will eventually replace /api/ds/query | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 8e84aaf464e..8c01b34d83e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -113,6 +113,7 @@ export interface FeatureToggles { kubernetesCliDashboards?: boolean; kubernetesRestore?: boolean; kubernetesFolders?: boolean; + kubernetesFoldersServiceV2?: boolean; grafanaAPIServerTestingWithExperimentalAPIs?: boolean; datasourceQueryTypes?: boolean; queryService?: boolean; diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index b5a840d0cf9..45965caa319 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -834,7 +834,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr quotaService := quotatest.New(false, nil) folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, db, features, - supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) if dashboardService == nil { dashboardService, err = service.ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 8e1738af28d..5232020efa8 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -57,7 +57,7 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) }) }) - if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) && !hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { // Use k8s client to implement legacy API handler := newFolderK8sHandler(hs) folderRoute.Post("/", handler.createFolder) diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index 6acd70c968a..cb0d7fe8672 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -461,7 +461,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog actionSets := resourcepermissions.NewActionSetService(features) fStore := folderimpl.ProvideStore(sc.db) folderServiceWithFlagOn := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, - folderStore, sc.db, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderStore, sc.db, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) acSvc := acimpl.ProvideOSSService( sc.cfg, acdb.ProvideService(sc.db), actionSets, localcache.ProvideService(), features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db, permreg.ProvidePermissionRegistry(), nil, folderServiceWithFlagOn, diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 93a40fe2981..811fe274f31 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -52,6 +52,11 @@ func LegacyUpdateCommandToUnstructured(obj *unstructured.Unstructured, cmd *fold if cmd.NewDescription != nil { spec["description"] = cmd.NewDescription } + if cmd.NewParentUID != nil { + if err := setParentUID(obj, *cmd.NewParentUID); err != nil { + return &unstructured.Unstructured{}, err + } + } return obj, nil } diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index 559933208a8..25321eea3ce 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -97,7 +97,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO } // List must return all folders - hits, err := s.service.GetFolders(ctx, folder.GetFoldersQuery{ + hits, err := s.service.GetFoldersLegacy(ctx, folder.GetFoldersQuery{ SignedInUser: user, OrgID: orgId, // TODO: enable pagination @@ -133,7 +133,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge return nil, err } - dto, err := s.service.Get(ctx, &folder.GetFolderQuery{ + dto, err := s.service.GetLegacy(ctx, &folder.GetFolderQuery{ SignedInUser: user, UID: &name, OrgID: info.OrgID, @@ -186,7 +186,7 @@ func (s *legacyStorage) Create(ctx context.Context, parent := accessor.GetFolder() - out, err := s.service.Create(ctx, &folder.CreateFolderCommand{ + out, err := s.service.CreateLegacy(ctx, &folder.CreateFolderCommand{ SignedInUser: user, UID: p.Name, Title: p.Spec.Title, @@ -285,7 +285,7 @@ func (s *legacyStorage) Update(ctx context.Context, oldParent := mOld.GetFolder() newParent := mNew.GetFolder() if oldParent != newParent { - _, err = s.service.Move(ctx, &folder.MoveFolderCommand{ + _, err = s.service.MoveLegacy(ctx, &folder.MoveFolderCommand{ SignedInUser: user, UID: name, OrgID: info.OrgID, @@ -312,7 +312,7 @@ func (s *legacyStorage) Update(ctx context.Context, changed = true } if changed { - _, err = s.service.Update(ctx, cmd) + _, err = s.service.UpdateLegacy(ctx, cmd) if err != nil { return nil, false, err } @@ -340,7 +340,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio if !ok { return v, false, fmt.Errorf("expected a folder response from Get") } - err = s.service.Delete(ctx, &folder.DeleteFolderCommand{ + err = s.service.DeleteLegacy(ctx, &folder.DeleteFolderCommand{ UID: name, OrgID: info.OrgID, SignedInUser: user, diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 03e2edfccfe..3c43e5fb668 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -66,6 +66,7 @@ func RegisterAPIService(cfg *setting.Cfg, ) *FolderAPIBuilder { if !featuremgmt.AnyEnabled(features, featuremgmt.FlagKubernetesFolders, + featuremgmt.FlagKubernetesFoldersServiceV2, featuremgmt.FlagGrafanaAPIServerTestingWithExperimentalAPIs, featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, featuremgmt.FlagProvisioning) { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go b/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go index 57f3f43aa87..062bd27b535 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/testutil/testutil.go @@ -49,7 +49,7 @@ func ProvideFolderPermissions( folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) fService := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sqlStore, features, - supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) acSvc := acimpl.ProvideOSSService( cfg, acdb.ProvideService(sqlStore), actionSets, localcache.ProvideService(), diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go index 3933b590311..ec6b7d5a0dc 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol_test.go @@ -50,7 +50,7 @@ func TestIntegrationAuthorize(t *testing.T) { ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()) folderSvc := folderimpl.ProvideService(fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, sql, featuremgmt.WithFeatures(), - supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil, nil, quotatest.New(false, nil), nil) require.NoError(t, err) diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index 9f7fbe77164..b6b85e41aad 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -62,7 +62,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()) folderSvc := folderimpl.ProvideService(fStore, accesscontrolmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, sql, featuremgmt.WithFeatures(), - supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil, nil, quotatest.New(false, nil), nil) require.NoError(t, err) @@ -244,7 +244,7 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { fStore := folderimpl.ProvideStore(sql) folderStore := folderimpl.ProvideDashboardFolderStore(sql) folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, - folderStore, sql, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderStore, sql, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) dashSvc, err := dashboardsservice.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, features, accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil, nil, quotatest.New(false, nil), nil) require.NoError(t, err) diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index db185664ee4..fe0fc20be5c 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -300,7 +300,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { }) folderStore := folderimpl.ProvideStore(sqlStore) - folderSvc := folderimpl.ProvideService(folderStore, mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderSvc := folderimpl.ProvideService(folderStore, mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) parentUID := "" for i := 0; ; i++ { diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 9628d111b56..60f3e45cfd1 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -891,7 +891,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) fStore := folderimpl.ProvideStore(sqlStore) folderServiceWithFlagOn := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, - folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) user := &user.SignedInUser{ OrgID: 1, @@ -1010,7 +1010,7 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { fStore := folderimpl.ProvideStore(sqlStore) folderServiceWithFlagOn := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, - folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) user := &user.SignedInUser{ OrgID: 1, diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 692d6d54383..b02b0e5d6bd 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -372,13 +372,15 @@ func (dr *DashboardServiceImpl) BuildSaveDashboardCommand(ctx context.Context, d // Validate folder if dash.FolderUID != "" { - folder, err := dr.folderStore.GetFolderByUID(ctx, dash.OrgID, dash.FolderUID) - if err != nil { - return nil, err + if !dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + folder, err := dr.folderStore.GetFolderByUID(ctx, dash.OrgID, dash.FolderUID) + if err != nil { + return nil, err + } + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() + // nolint:staticcheck + dash.FolderID = folder.ID } - metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() - // nolint:staticcheck - dash.FolderID = folder.ID } else if dash.FolderID != 0 { // nolint:staticcheck metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck diff --git a/pkg/services/dashboards/service/zanzana_integration_test.go b/pkg/services/dashboards/service/zanzana_integration_test.go index bedd2fe4ffc..8e9b85fe02e 100644 --- a/pkg/services/dashboards/service/zanzana_integration_test.go +++ b/pkg/services/dashboards/service/zanzana_integration_test.go @@ -101,6 +101,7 @@ func TestIntegrationDashboardServiceZanzana(t *testing.T) { db, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), supportbundlestest.NewFakeBundleService(), + cfg, nil, tracing.InitializeTracerForTest(), ) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 95130d80dd8..5fd9f61794d 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -714,6 +714,12 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaSearchAndStorageSquad, }, + { + Name: "kubernetesFoldersServiceV2", + Description: "Use the Folders Service V2, and route Folder Service requests to k8s", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + }, { Name: "grafanaAPIServerTestingWithExperimentalAPIs", Description: "Facilitate integration testing of experimental APIs", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8b5f0c8d11f..c414b28d25a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -94,6 +94,7 @@ kubernetesDashboards,experimental,@grafana/grafana-app-platform-squad,false,fals kubernetesCliDashboards,experimental,@grafana/grafana-app-platform-squad,false,false,false kubernetesRestore,experimental,@grafana/grafana-app-platform-squad,false,false,false kubernetesFolders,experimental,@grafana/search-and-storage,false,false,false +kubernetesFoldersServiceV2,experimental,@grafana/search-and-storage,false,false,false grafanaAPIServerTestingWithExperimentalAPIs,experimental,@grafana/search-and-storage,false,false,false datasourceQueryTypes,experimental,@grafana/grafana-app-platform-squad,false,true,false queryService,experimental,@grafana/grafana-app-platform-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 473d0603c4a..8ff9b539d28 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -387,6 +387,10 @@ const ( // Use the kubernetes API in the frontend for folders, and route /api/folders requests to k8s FlagKubernetesFolders = "kubernetesFolders" + // FlagKubernetesFoldersServiceV2 + // Use the Folders Service V2, and route Folder Service requests to k8s + FlagKubernetesFoldersServiceV2 = "kubernetesFoldersServiceV2" + // FlagGrafanaAPIServerTestingWithExperimentalAPIs // Facilitate integration testing of experimental APIs FlagGrafanaAPIServerTestingWithExperimentalAPIs = "grafanaAPIServerTestingWithExperimentalAPIs" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 49fe6f2c7ce..a5704c0ffa0 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2081,6 +2081,18 @@ "codeowner": "@grafana/search-and-storage" } }, + { + "metadata": { + "name": "kubernetesFoldersServiceV2", + "resourceVersion": "1735336477446", + "creationTimestamp": "2024-12-27T21:54:37Z" + }, + "spec": { + "description": "Use the Folders Service V2, and route Folder Service requests to k8s", + "stage": "experimental", + "codeowner": "@grafana/search-and-storage" + } + }, { "metadata": { "name": "kubernetesPlaylists", diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index 4e7037a8074..091e8c83e9a 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -18,12 +18,14 @@ import ( "golang.org/x/exp/slices" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -34,6 +36,7 @@ import ( "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/supportbundles" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -41,12 +44,14 @@ const FULLPATH_SEPARATOR = "/" type Service struct { store folder.Store + unifiedStore folder.Store db db.DB log *slog.Logger dashboardStore dashboards.Store dashboardFolderStore folder.FolderStore features featuremgmt.FeatureToggles accessControl accesscontrol.AccessControl + k8sclient folderK8sHandler // bus is currently used to publish event in case of folder full path change. // For example when a folder is moved to another folder or when a folder is renamed. bus bus.Bus @@ -66,14 +71,24 @@ func ProvideService( db db.DB, // DB for the (new) nested folder store features featuremgmt.FeatureToggles, supportBundles supportbundles.Service, + cfg *setting.Cfg, r prometheus.Registerer, tracer tracing.Tracer, ) *Service { + k8sHandler := &foldk8sHandler{ + gvr: v0alpha1.FolderResourceInfo.GroupVersionResource(), + namespacer: request.GetNamespaceMapper(cfg), + cfg: cfg, + } + + unifiedStore := ProvideUnifiedStore(cfg) + srv := &Service{ log: slog.Default().With("logger", "folder-service"), dashboardStore: dashboardStore, dashboardFolderStore: folderStore, store: store, + unifiedStore: unifiedStore, features: features, accessControl: ac, bus: bus, @@ -81,6 +96,7 @@ func ProvideService( registry: make(map[string]folder.RegistryService), metrics: newFoldersMetrics(r), tracer: tracer, + k8sclient: k8sHandler, } srv.DBMigration(db) @@ -138,6 +154,13 @@ func (s *Service) DBMigration(db db.DB) { } func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.getFoldersFromApiServer(ctx, q) + } + return s.GetFoldersLegacy(ctx, q) +} + +func (s *Service) GetFoldersLegacy(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { if q.SignedInUser == nil { return nil, folder.ErrBadRequest.Errorf("missing signed in user") } @@ -190,6 +213,13 @@ func (s *Service) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]* } func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.getFromApiServer(ctx, q) + } + return s.GetLegacy(ctx, q) +} + +func (s *Service) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { if q.SignedInUser == nil { return nil, folder.ErrBadRequest.Errorf("missing signed in user") } @@ -278,13 +308,13 @@ func (s *Service) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Fo } if s.features.IsEnabled(ctx, featuremgmt.FlagKubernetesFolders) { - f, err = s.setFullpath(ctx, f, q.SignedInUser) + f, err = s.setFullpath(ctx, f, q.SignedInUser, true) } return f, err } -func (s *Service) setFullpath(ctx context.Context, f *folder.Folder, user identity.Requester) (*folder.Folder, error) { +func (s *Service) setFullpath(ctx context.Context, f *folder.Folder, user identity.Requester, forceLegacy bool) (*folder.Folder, error) { // #TODO is some kind of intermediate conversion required as is the case with user id where // it gets parsed using UserIdentifier(). Also is there some kind of validation taking place as // part of the parsing? @@ -297,10 +327,19 @@ func (s *Service) setFullpath(ctx context.Context, f *folder.Folder, user identi // Fetch the parent since the permissions for fetching the newly created folder // are not yet present for the user--this requires a call to ClearUserPermissionCache - parents, err := s.GetParents(ctx, folder.GetParentsQuery{ - UID: f.UID, - OrgID: f.OrgID, - }) + var parents []*folder.Folder + var err error + if forceLegacy { + parents, err = s.GetParentsLegacy(ctx, folder.GetParentsQuery{ + UID: f.UID, + OrgID: f.OrgID, + }) + } else { + parents, err = s.GetParents(ctx, folder.GetParentsQuery{ + UID: f.UID, + OrgID: f.OrgID, + }) + } if err != nil { return nil, err } @@ -319,6 +358,13 @@ func (s *Service) setFullpath(ctx context.Context, f *folder.Folder, user identi } func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.getChildrenFromApiServer(ctx, q) + } + return s.GetChildrenLegacy(ctx, q) +} + +func (s *Service) GetChildrenLegacy(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { defer func(t time.Time) { parent := q.UID if q.UID != folder.SharedWithMeFolderUID { @@ -332,7 +378,7 @@ func (s *Service) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ( } if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && q.UID == folder.SharedWithMeFolderUID { - return s.GetSharedWithMe(ctx, q) + return s.GetSharedWithMe(ctx, q, true) } if q.UID == "" { @@ -460,14 +506,19 @@ func (s *Service) getRootFolders(ctx context.Context, q *folder.GetChildrenQuery } // GetSharedWithMe returns folders available to user, which cannot be accessed from the root folders -func (s *Service) GetSharedWithMe(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { +func (s *Service) GetSharedWithMe(ctx context.Context, q *folder.GetChildrenQuery, forceLegacy bool) ([]*folder.Folder, error) { start := time.Now() - availableNonRootFolders, err := s.getAvailableNonRootFolders(ctx, q) + availableNonRootFolders, err := s.getAvailableNonRootFolders(ctx, q, forceLegacy) if err != nil { s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("failure").Observe(time.Since(start).Seconds()) return nil, folder.ErrInternal.Errorf("failed to fetch subfolders to which the user has explicit access: %w", err) } - rootFolders, err := s.GetChildren(ctx, &folder.GetChildrenQuery{UID: "", OrgID: q.OrgID, SignedInUser: q.SignedInUser, Permission: q.Permission}) + var rootFolders []*folder.Folder + if forceLegacy { + rootFolders, err = s.GetChildrenLegacy(ctx, &folder.GetChildrenQuery{UID: "", OrgID: q.OrgID, SignedInUser: q.SignedInUser, Permission: q.Permission}) + } else { + rootFolders, err = s.GetChildren(ctx, &folder.GetChildrenQuery{UID: "", OrgID: q.OrgID, SignedInUser: q.SignedInUser, Permission: q.Permission}) + } if err != nil { s.metrics.sharedWithMeFetchFoldersRequestsDuration.WithLabelValues("failure").Observe(time.Since(start).Seconds()) return nil, folder.ErrInternal.Errorf("failed to fetch root folders to which the user has access: %w", err) @@ -478,7 +529,7 @@ func (s *Service) GetSharedWithMe(ctx context.Context, q *folder.GetChildrenQuer return availableNonRootFolders, nil } -func (s *Service) getAvailableNonRootFolders(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { +func (s *Service) getAvailableNonRootFolders(ctx context.Context, q *folder.GetChildrenQuery, forceLegacy bool) ([]*folder.Folder, error) { permissions := q.SignedInUser.GetPermissions() var folderPermissions []string if q.Permission == dashboardaccess.PERMISSION_EDIT { @@ -507,13 +558,25 @@ func (s *Service) getAvailableNonRootFolders(ctx context.Context, q *folder.GetC return nonRootFolders, nil } - dashFolders, err := s.GetFolders(ctx, folder.GetFoldersQuery{ - UIDs: folderUids, - OrgID: q.OrgID, - SignedInUser: q.SignedInUser, - OrderByTitle: true, - WithFullpathUIDs: true, - }) + var dashFolders []*folder.Folder + var err error + if forceLegacy { + dashFolders, err = s.GetFoldersLegacy(ctx, folder.GetFoldersQuery{ + UIDs: folderUids, + OrgID: q.OrgID, + SignedInUser: q.SignedInUser, + OrderByTitle: true, + WithFullpathUIDs: true, + }) + } else { + dashFolders, err = s.GetFolders(ctx, folder.GetFoldersQuery{ + UIDs: folderUids, + OrgID: q.OrgID, + SignedInUser: q.SignedInUser, + OrderByTitle: true, + WithFullpathUIDs: true, + }) + } if err != nil { return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err) } @@ -565,6 +628,13 @@ func (s *Service) deduplicateAvailableFolders(ctx context.Context, folders []*fo } func (s *Service) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.getParentsFromApiServer(ctx, q) + } + return s.GetParentsLegacy(ctx, q) +} + +func (s *Service) GetParentsLegacy(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) || q.UID == accesscontrol.GeneralFolderUID { return nil, nil } @@ -591,6 +661,13 @@ func (s *Service) getFolderByTitle(ctx context.Context, orgID int64, title strin } func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.createOnApiServer(ctx, cmd) + } + return s.CreateLegacy(ctx, cmd) +} + +func (s *Service) CreateLegacy(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { if cmd.SignedInUser == nil || cmd.SignedInUser.IsNil() { return nil, folder.ErrBadRequest.Errorf("missing signed in user") } @@ -700,13 +777,23 @@ func (s *Service) Create(ctx context.Context, cmd *folder.CreateFolderCommand) ( } if s.features.IsEnabled(ctx, featuremgmt.FlagKubernetesFolders) { - f, err = s.setFullpath(ctx, f, user) + f, err = s.setFullpath(ctx, f, user, true) + if err != nil { + return nil, err + } } return f, nil } func (s *Service) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.updateOnApiServer(ctx, cmd) + } + return s.UpdateLegacy(ctx, cmd) +} + +func (s *Service) UpdateLegacy(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { ctx, span := s.tracer.Start(ctx, "folder.Update") defer span.End() @@ -839,6 +926,13 @@ func prepareForUpdate(dashFolder *dashboards.Dashboard, orgId int64, userId int6 } func (s *Service) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) error { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.deleteFromApiServer(ctx, cmd) + } + return s.DeleteLegacy(ctx, cmd) +} + +func (s *Service) DeleteLegacy(ctx context.Context, cmd *folder.DeleteFolderCommand) error { if cmd.SignedInUser == nil { return folder.ErrBadRequest.Errorf("missing signed in user") } @@ -922,6 +1016,13 @@ func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderComm } func (s *Service) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.moveOnApiServer(ctx, cmd) + } + return s.MoveLegacy(ctx, cmd) +} + +func (s *Service) MoveLegacy(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { ctx, span := s.tracer.Start(ctx, "folder.Move") defer span.End() @@ -1142,6 +1243,14 @@ func (s *Service) nestedFolderDelete(ctx context.Context, cmd *folder.DeleteFold } func (s *Service) GetDescendantCounts(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { + if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFoldersServiceV2) { + return s.getDescendantCountsFromApiServer(ctx, q) + } + + return s.GetDescendantCountsLegacy(ctx, q) +} + +func (s *Service) GetDescendantCountsLegacy(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { if q.SignedInUser == nil { return nil, folder.ErrBadRequest.Errorf("missing signed-in user") } diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index 132247791c3..f3c00291b26 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -62,10 +62,10 @@ func TestIntegrationProvideFolderService(t *testing.T) { } t.Run("should register scope resolvers", func(t *testing.T) { ac := acmock.New() - db, _ := db.InitTestDBWithCfg(t) + db, cfg := db.InitTestDBWithCfg(t) store := ProvideStore(db) ProvideService(store, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), nil, nil, db, - featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) require.Len(t, ac.Calls.RegisterAttributeScopeResolver, 2) }) diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go new file mode 100644 index 00000000000..d8f9a5b8b76 --- /dev/null +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -0,0 +1,702 @@ +package folderimpl + +import ( + "context" + "strings" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/exp/slices" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/events" + "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/store/entity" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +// interface to allow for testing +type folderK8sHandler interface { + getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) + getNamespace(orgID int64) string +} + +var _ folderK8sHandler = (*foldk8sHandler)(nil) + +type foldk8sHandler struct { + cfg *setting.Cfg + namespacer request.NamespaceMapper + gvr schema.GroupVersionResource +} + +func (s *Service) getFoldersFromApiServer(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { + if q.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + qry := folder.NewGetFoldersQuery(q) + permissions := q.SignedInUser.GetPermissions() + folderPermissions := permissions[dashboards.ActionFoldersRead] + qry.AncestorUIDs = make([]string, 0, len(folderPermissions)) + if len(folderPermissions) == 0 && !q.SignedInUser.GetIsGrafanaAdmin() { + return nil, nil + } + for _, p := range folderPermissions { + if p == dashboards.ScopeFoldersAll { + // no need to query for folders with permissions + // the user has permission to access all folders + qry.AncestorUIDs = nil + break + } + if folderUid, found := strings.CutPrefix(p, dashboards.ScopeFoldersPrefix); found { + if !slices.Contains(qry.AncestorUIDs, folderUid) { + qry.AncestorUIDs = append(qry.AncestorUIDs, folderUid) + } + } + } + + var dashFolders []*folder.Folder + var err error + + ctx = identity.WithRequester(ctx, q.SignedInUser) + dashFolders, err = s.unifiedStore.GetFolders(ctx, qry) + if err != nil { + return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err) + } + + return dashFolders, nil +} + +func (s *Service) getFromApiServer(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { + if q.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + if q.UID != nil && *q.UID == accesscontrol.GeneralFolderUID { + return folder.RootFolder, nil + } + + if q.UID != nil && *q.UID == folder.SharedWithMeFolderUID { + return folder.SharedWithMeFolder.WithURL(), nil + } + + var dashFolder *folder.Folder + var err error + switch { + case q.UID != nil: + if *q.UID == "" { + return &folder.GeneralFolder, nil + } + dashFolder, err = s.unifiedStore.Get(ctx, *q) + if err != nil { + return nil, err + } + // nolint:staticcheck + case q.ID != nil: + // not implemented + case q.Title != nil: + // not implemented + default: + return nil, folder.ErrBadRequest.Errorf("either on of UID, ID, Title fields must be present") + } + + if dashFolder.IsGeneral() { + return dashFolder, nil + } + + // do not get guardian by the folder ID because it differs from the nested folder ID + // and the legacy folder ID has been associated with the permissions: + // use the folde UID instead that is the same for both + g, err := guardian.NewByFolder(ctx, dashFolder, dashFolder.OrgID, q.SignedInUser) + if err != nil { + return nil, err + } + + if canView, err := g.CanView(); err != nil || !canView { + if err != nil { + return nil, toFolderError(err) + } + return nil, dashboards.ErrFolderAccessDenied + } + + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() + // nolint:staticcheck + if q.ID != nil { + q.ID = nil + q.UID = &dashFolder.UID + } + + f := dashFolder + + // always expose the dashboard store sequential ID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() + // nolint:staticcheck + f.ID = dashFolder.ID + f.Version = dashFolder.Version + + f, err = s.setFullpath(ctx, f, q.SignedInUser, false) + if err != nil { + return nil, err + } + + return f, err +} + +func (s *Service) getChildrenFromApiServer(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { + defer func(t time.Time) { + parent := q.UID + if q.UID != folder.SharedWithMeFolderUID { + parent = "folder" + } + s.metrics.foldersGetChildrenRequestsDuration.WithLabelValues(parent).Observe(time.Since(t).Seconds()) + }(time.Now()) + + if q.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + if q.UID == folder.SharedWithMeFolderUID { + return s.GetSharedWithMe(ctx, q, false) + } + + if q.UID == "" { + return s.getRootFoldersFromApiServer(ctx, q) + } + + var err error + // TODO: figure out what to do with Guardian + // we only need to check access to the folder + // if the parent is accessible then the subfolders are accessible as well (due to inheritance) + f := &folder.Folder{ + UID: q.UID, + } + g, err := guardian.NewByFolder(ctx, f, q.OrgID, q.SignedInUser) + if err != nil { + return nil, err + } + + guardianFunc := g.CanView + if q.Permission == dashboardaccess.PERMISSION_EDIT { + guardianFunc = g.CanEdit + } + + hasAccess, err := guardianFunc() + if err != nil { + return nil, err + } + if !hasAccess { + return nil, dashboards.ErrFolderAccessDenied + } + + children, err := s.unifiedStore.GetChildren(ctx, *q) + if err != nil { + return nil, err + } + + return children, nil +} + +func (s *Service) getRootFoldersFromApiServer(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { + permissions := q.SignedInUser.GetPermissions() + var folderPermissions []string + if q.Permission == dashboardaccess.PERMISSION_EDIT { + folderPermissions = permissions[dashboards.ActionFoldersWrite] + } else { + folderPermissions = permissions[dashboards.ActionFoldersRead] + } + + if len(folderPermissions) == 0 && !q.SignedInUser.GetIsGrafanaAdmin() { + return nil, nil + } + + q.FolderUIDs = make([]string, 0, len(folderPermissions)) + for _, p := range folderPermissions { + if p == dashboards.ScopeFoldersAll { + // no need to query for folders with permissions + // the user has permission to access all folders + q.FolderUIDs = nil + break + } + if folderUid, found := strings.CutPrefix(p, dashboards.ScopeFoldersPrefix); found { + if !slices.Contains(q.FolderUIDs, folderUid) { + q.FolderUIDs = append(q.FolderUIDs, folderUid) + } + } + } + + children, err := s.unifiedStore.GetChildren(ctx, *q) + if err != nil { + return nil, err + } + + // add "shared with me" folder on the 1st page + if (q.Page == 0 || q.Page == 1) && len(q.FolderUIDs) != 0 { + children = append([]*folder.Folder{&folder.SharedWithMeFolder}, children...) + } + + return children, nil +} + +func (s *Service) getParentsFromApiServer(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { + if q.UID == accesscontrol.GeneralFolderUID { + return nil, nil + } + if q.UID == folder.SharedWithMeFolderUID { + return []*folder.Folder{&folder.SharedWithMeFolder}, nil + } + + return s.unifiedStore.GetParents(ctx, q) +} + +func (s *Service) createOnApiServer(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + if cmd.SignedInUser == nil || cmd.SignedInUser.IsNil() { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + if cmd.ParentUID != "" { + // Check that the user is allowed to create a subfolder in this folder + parentUIDScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.ParentUID) + legacyEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, parentUIDScope) + newEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersCreate, parentUIDScope) + evaluator := accesscontrol.EvalAny(legacyEvaluator, newEvaluator) + hasAccess, evalErr := s.accessControl.Evaluate(ctx, cmd.SignedInUser, evaluator) + if evalErr != nil { + return nil, evalErr + } + if !hasAccess { + return nil, dashboards.ErrFolderCreationAccessDenied.Errorf("user is missing the permission with action either folders:create or folders:write and scope %s or any of the parent folder scopes", parentUIDScope) + } + } else { + evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersCreate, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)) + hasAccess, evalErr := s.accessControl.Evaluate(ctx, cmd.SignedInUser, evaluator) + if evalErr != nil { + return nil, evalErr + } + if !hasAccess { + return nil, dashboards.ErrFolderCreationAccessDenied.Errorf("user is missing the permission with action folders:create and scope folders:uid:general, which is required to create a folder under the root level") + } + } + + if cmd.UID == folder.SharedWithMeFolderUID { + return nil, folder.ErrBadRequest.Errorf("cannot create folder with UID %s", folder.SharedWithMeFolderUID) + } + + trimmedUID := strings.TrimSpace(cmd.UID) + if trimmedUID == accesscontrol.GeneralFolderUID { + return nil, dashboards.ErrFolderInvalidUID + } + + user := cmd.SignedInUser + + cmd = &folder.CreateFolderCommand{ + // TODO: Today, if a UID isn't specified, the dashboard store + // generates a new UID. The new folder store will need to do this as + // well, but for now we take the UID from the newly created folder. + UID: trimmedUID, + OrgID: cmd.OrgID, + Title: cmd.Title, + Description: cmd.Description, + ParentUID: cmd.ParentUID, + SignedInUser: cmd.SignedInUser, + } + + f, err := s.unifiedStore.Create(ctx, *cmd) + if err != nil { + return nil, err + } + + f, err = s.setFullpath(ctx, f, user, false) + if err != nil { + return nil, err + } + + return f, nil +} + +func (s *Service) updateOnApiServer(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { + ctx, span := s.tracer.Start(ctx, "folder.Update") + defer span.End() + + if cmd.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + if cmd.NewTitle != nil && *cmd.NewTitle != "" { + title := strings.TrimSpace(*cmd.NewTitle) + cmd.NewTitle = &title + + if strings.EqualFold(*cmd.NewTitle, dashboards.RootFolderName) { + return nil, dashboards.ErrDashboardFolderNameExists + } + } + + if !util.IsValidShortUID(cmd.UID) { + return nil, dashboards.ErrDashboardInvalidUid + } else if util.IsShortUIDTooLong(cmd.UID) { + return nil, dashboards.ErrDashboardUidTooLong + } + + cmd.UID = strings.TrimSpace(cmd.UID) + + if cmd.NewTitle != nil && *cmd.NewTitle == "" { + return nil, dashboards.ErrDashboardTitleEmpty + } + + f := &folder.Folder{ + UID: cmd.UID, + } + g, err := guardian.NewByFolder(ctx, f, cmd.OrgID, cmd.SignedInUser) + if err != nil { + return nil, err + } + + if canSave, err := g.CanSave(); err != nil || !canSave { + if err != nil { + return nil, err + } + return nil, toFolderError(dashboards.ErrDashboardUpdateAccessDenied) + } + + user := cmd.SignedInUser + + foldr, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ + UID: cmd.UID, + OrgID: cmd.OrgID, + NewTitle: cmd.NewTitle, + NewDescription: cmd.NewDescription, + SignedInUser: user, + }) + + if err != nil { + return nil, err + } + + if cmd.NewTitle != nil { + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() + + if err := s.publishFolderFullPathUpdatedEventViaApiServer(ctx, foldr.Updated, cmd.OrgID, cmd.UID); err != nil { + return nil, err + } + } + + // always expose the dashboard store sequential ID + metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc() + + return foldr, nil +} + +func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFolderCommand) error { + if cmd.SignedInUser == nil { + return folder.ErrBadRequest.Errorf("missing signed in user") + } + if cmd.UID == "" { + return folder.ErrBadRequest.Errorf("missing UID") + } + if cmd.OrgID < 1 { + return folder.ErrBadRequest.Errorf("invalid orgID") + } + + f := &folder.Folder{ + UID: cmd.UID, + } + guard, err := guardian.NewByFolder(ctx, f, cmd.OrgID, cmd.SignedInUser) + if err != nil { + return err + } + + if canSave, err := guard.CanDelete(); err != nil || !canSave { + if err != nil { + return toFolderError(err) + } + return dashboards.ErrFolderAccessDenied + } + + descFolders, err := s.unifiedStore.GetDescendants(ctx, cmd.OrgID, cmd.UID) + if err != nil { + return err + } + + folders := []string{cmd.UID} + for _, f := range descFolders { + folders = append(folders, f.UID) + } + + if cmd.ForceDeleteRules { + if err := s.deleteChildrenInFolder(ctx, cmd.OrgID, folders, cmd.SignedInUser); err != nil { + return err + } + } else { + alertRuleSrv, ok := s.registry[entity.StandardKindAlertRule] + if !ok { + return folder.ErrInternal.Errorf("no alert rule service found in registry") + } + alertRulesInFolder, err := alertRuleSrv.CountInFolders(ctx, cmd.OrgID, folders, cmd.SignedInUser) + if err != nil { + s.log.Error("failed to count alert rules in folder", "error", err) + return err + } + if alertRulesInFolder > 0 { + return folder.ErrFolderNotEmpty.Errorf("folder contains %d alert rules", alertRulesInFolder) + } + } + + err = s.unifiedStore.Delete(ctx, folders, cmd.OrgID) + if err != nil { + return err + } + + return nil +} + +func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { + ctx, span := s.tracer.Start(ctx, "folder.Move") + defer span.End() + + if cmd.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed in user") + } + + // k6-specific check to prevent folder move for a k6-app folder and its children + if cmd.UID == accesscontrol.K6FolderUID { + return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") + } + + f, err := s.unifiedStore.Get(ctx, folder.GetFolderQuery{ + UID: &cmd.UID, + OrgID: cmd.OrgID, + SignedInUser: cmd.SignedInUser, + }) + if err != nil { + return nil, err + } + + if f != nil && f.ParentUID == accesscontrol.K6FolderUID { + return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") + } + + // Check that the user is allowed to move the folder to the destination folder + hasAccess, evalErr := s.canMoveViaApiServer(ctx, cmd) + if evalErr != nil { + return nil, evalErr + } + if !hasAccess { + return nil, dashboards.ErrFolderAccessDenied + } + + // here we get the folder, we need to get the height of current folder + // and the depth of the new parent folder, the sum can't bypass 8 + folderHeight, err := s.unifiedStore.GetHeight(ctx, cmd.UID, cmd.OrgID, &cmd.NewParentUID) + if err != nil { + return nil, err + } + parents, err := s.unifiedStore.GetParents(ctx, folder.GetParentsQuery{UID: cmd.NewParentUID, OrgID: cmd.OrgID}) + if err != nil { + return nil, err + } + + // height of the folder that is being moved + this current folder itself + depth of the NewParent folder should be less than or equal MaxNestedFolderDepth + if folderHeight+len(parents)+1 > folder.MaxNestedFolderDepth { + return nil, folder.ErrMaximumDepthReached.Errorf("failed to move folder") + } + + for _, parent := range parents { + // if the current folder is already a parent of newparent, we should return error + if parent.UID == cmd.UID { + return nil, folder.ErrCircularReference.Errorf("failed to move folder") + } + } + + f, err = s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ + UID: cmd.UID, + OrgID: cmd.OrgID, + NewParentUID: &cmd.NewParentUID, + SignedInUser: cmd.SignedInUser, + }) + if err != nil { + return nil, folder.ErrInternal.Errorf("failed to move folder: %w", err) + } + + if err := s.publishFolderFullPathUpdatedEventViaApiServer(ctx, f.Updated, cmd.OrgID, cmd.UID); err != nil { + return nil, err + } + + return f, nil +} + +func (s *Service) publishFolderFullPathUpdatedEventViaApiServer(ctx context.Context, timestamp time.Time, orgID int64, folderUID string) error { + ctx, span := s.tracer.Start(ctx, "folder.publishFolderFullPathUpdatedEventViaApiServer") + defer span.End() + + descFolders, err := s.unifiedStore.GetDescendants(ctx, orgID, folderUID) + if err != nil { + s.log.ErrorContext(ctx, "Failed to get descendants of the folder", "folderUID", folderUID, "orgID", orgID, "error", err) + return err + } + + uids := make([]string, 0, len(descFolders)+1) + uids = append(uids, folderUID) + for _, f := range descFolders { + uids = append(uids, f.UID) + } + span.AddEvent("found folder descendants", trace.WithAttributes( + attribute.Int64("folders", int64(len(uids))), + )) + + if err := s.bus.Publish(ctx, &events.FolderFullPathUpdated{ + Timestamp: timestamp, + UIDs: uids, + OrgID: orgID, + }); err != nil { + s.log.ErrorContext(ctx, "Failed to publish FolderFullPathUpdated event", "folderUID", folderUID, "orgID", orgID, "descendantsUIDs", uids, "error", err) + return err + } + + return nil +} + +func (s *Service) canMoveViaApiServer(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 != "" { + legacyEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.NewParentUID)) + newEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersCreate, dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.NewParentUID)) + evaluator = accesscontrol.EvalAny(legacyEvaluator, newEvaluator) + } else { + // Evaluate folder creation permission when moving folder to the root level + evaluator = accesscontrol.EvalPermission(dashboards.ActionFoldersCreate, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder.GeneralFolderUID)) + 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.getFolderAndParentUIDScopesViaApiServer(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 { + // Skip unexpanded action sets - they have no impact if action sets are not enabled + if !s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) { + if action == "folders:view" || action == "folders:edit" || action == "folders:admin" { + continue + } + } + 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) getFolderAndParentUIDScopesViaApiServer(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.unifiedStore.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 +} + +func (s *Service) getDescendantCountsFromApiServer(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { + if q.SignedInUser == nil { + return nil, folder.ErrBadRequest.Errorf("missing signed-in user") + } + if q.UID == nil || *q.UID == "" { + return nil, folder.ErrBadRequest.Errorf("missing UID") + } + if q.OrgID < 1 { + return nil, folder.ErrBadRequest.Errorf("invalid orgID") + } + + if s.features.IsEnabledGlobally(featuremgmt.FlagK8SFolderCounts) { + return s.unifiedStore.(*FolderUnifiedStoreImpl).CountFolderContent(ctx, q.OrgID, *q.UID) + } + + folders := []string{*q.UID} + countsMap := make(folder.DescendantCounts, len(s.registry)+1) + descendantFolders, err := s.unifiedStore.GetDescendants(ctx, q.OrgID, *q.UID) + if err != nil { + s.log.ErrorContext(ctx, "failed to get descendant folders", "error", err) + return nil, err + } + + for _, f := range descendantFolders { + folders = append(folders, f.UID) + } + countsMap[entity.StandardKindFolder] = int64(len(descendantFolders)) + + for _, v := range s.registry { + c, err := v.CountInFolders(ctx, q.OrgID, folders, q.SignedInUser) + if err != nil { + s.log.ErrorContext(ctx, "failed to count folder descendants", "error", err) + return nil, err + } + countsMap[v.Kind()] = c + } + return countsMap, nil +} + +// ----------------------------------------------------------------------------------------- +// Folder k8s functions +// ----------------------------------------------------------------------------------------- + +func (fk8s *foldk8sHandler) getClient(ctx context.Context, orgID int64) (dynamic.ResourceInterface, bool) { + cfg := &rest.Config{ + Host: fk8s.cfg.AppURL, + APIPath: "/apis", + TLSClientConfig: rest.TLSClientConfig{ + Insecure: true, // Skip TLS verification + }, + Username: fk8s.cfg.AdminUser, + Password: fk8s.cfg.AdminPassword, + } + + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, false + } + return dyn.Resource(fk8s.gvr).Namespace(fk8s.getNamespace(orgID)), true +} + +func (fk8s *foldk8sHandler) getNamespace(orgID int64) string { + return fk8s.namespacer(orgID) +} diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go new file mode 100644 index 00000000000..8f757975d6c --- /dev/null +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage_test.go @@ -0,0 +1,414 @@ +package folderimpl + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana/pkg/infra/tracing" + internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/guardian" + ngstore "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/user" +) + +func TestIntegrationFolderServiceViaUnifiedStorage(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + m := map[string]v0alpha1.Folder{} + + unifiedStorageFolder := &v0alpha1.Folder{} + unifiedStorageFolder.Kind = "folder" + + fooFolder := &folder.Folder{ + Title: "Foo Folder", + OrgID: orgID, + UID: "foo", + URL: "/dashboards/f/foo/foo-folder", + CreatedByUID: "user:1", + UpdatedByUID: "user:1", + } + + updateFolder := &folder.Folder{ + Title: "Folder", + OrgID: orgID, + UID: "updatefolder", + } + + mux := http.NewServeMux() + + mux.HandleFunc("DELETE /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/deletefolder", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + }) + + mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + l := &v0alpha1.FolderList{} + l.Kind = "Folder" + err := json.NewEncoder(w).Encode(l) + require.NoError(t, err) + }) + + mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/foo", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + namespacer := func(_ int64) string { return "1" } + result, err := internalfolders.LegacyFolderToUnstructured(fooFolder, namespacer) + require.NoError(t, err) + + err = json.NewEncoder(w).Encode(result) + require.NoError(t, err) + }) + + mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/updatefolder", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + namespacer := func(_ int64) string { return "1" } + result, err := internalfolders.LegacyFolderToUnstructured(updateFolder, namespacer) + require.NoError(t, err) + + err = json.NewEncoder(w).Encode(result) + require.NoError(t, err) + }) + + mux.HandleFunc("PUT /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/updatefolder", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + buf, err := io.ReadAll(req.Body) + require.NoError(t, err) + + var foldr v0alpha1.Folder + err = json.Unmarshal(buf, &foldr) + require.NoError(t, err) + + updateFolder.Title = foldr.Spec.Title + + namespacer := func(_ int64) string { return "1" } + result, err := internalfolders.LegacyFolderToUnstructured(updateFolder, namespacer) + require.NoError(t, err) + + err = json.NewEncoder(w).Encode(result) + require.NoError(t, err) + }) + + mux.HandleFunc("GET /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(unifiedStorageFolder) + require.NoError(t, err) + }) + mux.HandleFunc("PUT /apis/folder.grafana.app/v0alpha1/namespaces/default/folders/ady4yobv315a8e", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(unifiedStorageFolder) + require.NoError(t, err) + }) + mux.HandleFunc("POST /apis/folder.grafana.app/v0alpha1/namespaces/default/folders", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + buf, err := io.ReadAll(req.Body) + require.NoError(t, err) + + var folder v0alpha1.Folder + err = json.Unmarshal(buf, &folder) + require.NoError(t, err) + + m[folder.Name] = folder + + fmt.Printf("buf: %+v\n", folder) + folder.Kind = "Folder" + err = json.NewEncoder(w).Encode(folder) + require.NoError(t, err) + }) + + folderApiServerMock := httptest.NewServer(mux) + defer folderApiServerMock.Close() + + origNewGuardian := guardian.New + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + + db, cfg := sqlstore.InitTestDB(t) + cfg.AppURL = folderApiServerMock.URL + + unifiedStore := ProvideUnifiedStore(cfg) + + ctx := context.Background() + usr := &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByActionContext( + ctx, + []accesscontrol.Permission{ + {Action: dashboards.ActionFoldersCreate, Scope: dashboards.ScopeFoldersAll}, + {Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll}, + {Action: accesscontrol.ActionAlertingRuleDelete, Scope: dashboards.ScopeFoldersAll}, + }), + }} + + alertingStore := ngstore.DBstore{ + SQLStore: db, + Cfg: cfg.UnifiedAlerting, + Logger: log.New("test-alerting-store"), + AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true}, + } + + featuresArr := []any{ + featuremgmt.FlagKubernetesFolders, + featuremgmt.FlagKubernetesFoldersServiceV2} + features := featuremgmt.WithFeatures(featuresArr...) + + folderService := &Service{ + log: slog.New(logtest.NewTestHandler(t)).With("logger", "test-folder-service"), + unifiedStore: unifiedStore, + features: features, + bus: bus.ProvideBus(tracing.InitializeTracerForTest()), + // db: db, + accessControl: acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()), + registry: make(map[string]folder.RegistryService), + metrics: newFoldersMetrics(nil), + tracer: tracing.InitializeTracerForTest(), + } + + require.NoError(t, folderService.RegisterService(alertingStore)) + + t.Run("Folder service tests", func(t *testing.T) { + t.Run("Given user has no permissions", func(t *testing.T) { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{}) + + ctx = identity.WithRequester(context.Background(), noPermUsr) + + f := folder.NewFolder("Folder", "") + f.UID = "foo" + + t.Run("When get folder by id should return access denied error", func(t *testing.T) { + _, err := folderService.Get(ctx, &folder.GetFolderQuery{ + UID: &f.UID, + OrgID: orgID, + SignedInUser: noPermUsr, + }) + require.Equal(t, dashboards.ErrFolderAccessDenied, err) + }) + + t.Run("When get folder by uid should return access denied error", func(t *testing.T) { + _, err := folderService.Get(ctx, &folder.GetFolderQuery{ + UID: &f.UID, + OrgID: orgID, + SignedInUser: noPermUsr, + }) + require.Equal(t, dashboards.ErrFolderAccessDenied, err) + }) + + t.Run("When creating folder should return access denied error", func(t *testing.T) { + _, err := folderService.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: f.Title, + UID: f.UID, + SignedInUser: noPermUsr, + }) + require.Error(t, err) + }) + + title := "Folder-TEST" + t.Run("When updating folder should return access denied error", func(t *testing.T) { + _, err := folderService.Update(ctx, &folder.UpdateFolderCommand{ + UID: f.UID, + OrgID: orgID, + NewTitle: &title, + SignedInUser: noPermUsr, + }) + require.Error(t, err) + require.Equal(t, dashboards.ErrFolderAccessDenied, err) + }) + + t.Run("When deleting folder by uid should return access denied error", func(t *testing.T) { + err := folderService.Delete(ctx, &folder.DeleteFolderCommand{ + UID: f.UID, + OrgID: orgID, + ForceDeleteRules: false, + SignedInUser: noPermUsr, + }) + require.Error(t, err) + require.Equal(t, dashboards.ErrFolderAccessDenied, err) + }) + + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + }) + + t.Run("Given user has permission to save", func(t *testing.T) { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true, CanViewValue: true}) + + ctx = identity.WithRequester(context.Background(), usr) + + f := &folder.Folder{ + OrgID: orgID, + Title: "Test-Folder", + UID: "testfolder", + URL: "/dashboards/f/testfolder/test-folder", + CreatedByUID: "user:1", + UpdatedByUID: "user:1", + } + + t.Run("When creating folder should not return access denied error", func(t *testing.T) { + actualFolder, err := folderService.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: f.Title, + UID: f.UID, + SignedInUser: usr, + }) + require.NoError(t, err) + require.Equal(t, f, actualFolder) + }) + + t.Run("When creating folder should return error if uid is general", func(t *testing.T) { + _, err := folderService.Create(ctx, &folder.CreateFolderCommand{ + OrgID: orgID, + Title: f.Title, + UID: "general", + SignedInUser: usr, + }) + require.ErrorIs(t, err, dashboards.ErrFolderInvalidUID) + }) + + t.Run("When updating folder should not return access denied error", func(t *testing.T) { + title := "TEST-Folder" + req := &folder.UpdateFolderCommand{ + UID: updateFolder.UID, + OrgID: orgID, + NewTitle: &title, + SignedInUser: usr, + } + + reqResult, err := folderService.Update(ctx, req) + require.NoError(t, err) + require.Equal(t, title, reqResult.Title) + }) + + t.Run("When deleting folder by uid should not return access denied error - ForceDeleteRules true", func(t *testing.T) { + err := folderService.Delete(ctx, &folder.DeleteFolderCommand{ + UID: "deletefolder", + OrgID: orgID, + ForceDeleteRules: true, + SignedInUser: usr, + }) + require.NoError(t, err) + }) + + t.Run("When deleting folder by uid should not return access denied error - ForceDeleteRules false", func(t *testing.T) { + err := folderService.Delete(ctx, &folder.DeleteFolderCommand{ + UID: "deletefolder", + OrgID: orgID, + ForceDeleteRules: false, + SignedInUser: usr, + }) + require.NoError(t, err) + }) + + t.Run("When deleting folder by uid, expectedForceDeleteRules as false, and dashboard Restore turned on should not return access denied error", func(t *testing.T) { + folderService.features = featuremgmt.WithFeatures(append(featuresArr, featuremgmt.FlagDashboardRestore)...) + + expectedForceDeleteRules := false + err := folderService.Delete(ctx, &folder.DeleteFolderCommand{ + UID: "deletefolder", + OrgID: orgID, + ForceDeleteRules: expectedForceDeleteRules, + SignedInUser: usr, + }) + require.NoError(t, err) + }) + + t.Run("When deleting folder by uid, expectedForceDeleteRules as true, and dashboard Restore turned on should not return access denied error", func(t *testing.T) { + folderService.features = featuremgmt.WithFeatures(append(featuresArr, featuremgmt.FlagDashboardRestore)...) + + expectedForceDeleteRules := true + err := folderService.Delete(ctx, &folder.DeleteFolderCommand{ + UID: "deletefolder", + OrgID: orgID, + ForceDeleteRules: expectedForceDeleteRules, + SignedInUser: usr, + }) + require.NoError(t, err) + }) + + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + }) + + t.Run("Given user has permission to view", func(t *testing.T) { + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true}) + + t.Run("When get folder by uid should return folder", func(t *testing.T) { + actual, err := folderService.Get(ctx, &folder.GetFolderQuery{ + UID: &fooFolder.UID, + OrgID: fooFolder.OrgID, + SignedInUser: usr, + }) + require.Equal(t, fooFolder, actual) + require.NoError(t, err) + }) + + t.Run("When get folder by uid and uid is general should return the root folder object", func(t *testing.T) { + uid := accesscontrol.GeneralFolderUID + query := &folder.GetFolderQuery{ + UID: &uid, + OrgID: 1, + SignedInUser: usr, + } + actual, err := folderService.Get(ctx, query) + require.Equal(t, folder.RootFolder, actual) + require.NoError(t, err) + }) + + // TODO!! + /* + t.Run("When get folder by title should return folder", func(t *testing.T) { + expected := folder.NewFolder("TEST-"+util.GenerateShortUID(), "") + + actual, err := service.getFolderByTitle(context.Background(), orgID, expected.Title, nil) + require.Equal(t, expected, actual) + require.NoError(t, err) + }) + */ + + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + }) + + t.Run("Returns root folder", func(t *testing.T) { + t.Run("When the folder UID is blank should return the root folder", func(t *testing.T) { + emptyString := "" + actual, err := folderService.Get(ctx, &folder.GetFolderQuery{ + UID: &emptyString, + OrgID: 1, + SignedInUser: usr, + }) + + require.NoError(t, err) + require.Equal(t, folder.GeneralFolder.UID, actual.UID) + require.Equal(t, folder.GeneralFolder.Title, actual.Title) + }) + }) + }) +} diff --git a/pkg/services/folder/folderimpl/unifiedstore.go b/pkg/services/folder/folderimpl/unifiedstore.go new file mode 100644 index 00000000000..55c1f5c462f --- /dev/null +++ b/pkg/services/folder/folderimpl/unifiedstore.go @@ -0,0 +1,520 @@ +package folderimpl + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sUser "k8s.io/apiserver/pkg/authentication/user" + k8sRequest "k8s.io/apiserver/pkg/endpoints/request" + + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +type FolderUnifiedStoreImpl struct { + log log.Logger + k8sclient folderK8sHandler +} + +// sqlStore implements the store interface. +var _ folder.Store = (*FolderUnifiedStoreImpl)(nil) + +func ProvideUnifiedStore(cfg *setting.Cfg) *FolderUnifiedStoreImpl { + k8sHandler := &foldk8sHandler{ + gvr: v0alpha1.FolderResourceInfo.GroupVersionResource(), + namespacer: request.GetNamespaceMapper(cfg), + cfg: cfg, + } + + return &FolderUnifiedStoreImpl{ + k8sclient: k8sHandler, + log: log.New("folder-store"), + } +} + +func (ss *FolderUnifiedStoreImpl) Create(ctx context.Context, cmd folder.CreateFolderCommand) (*folder.Folder, error) { + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, cmd.OrgID) + if !ok { + return nil, nil + } + + obj, err := internalfolders.LegacyCreateCommandToUnstructured(&cmd) + if err != nil { + return nil, err + } + out, err := client.Create(newCtx, obj, v1.CreateOptions{}) + if err != nil { + return nil, err + } + + folder, _ := internalfolders.UnstructuredToLegacyFolder(*out, cmd.SignedInUser.GetOrgID()) + if err != nil { + return nil, err + } + + return folder, err +} + +func (ss *FolderUnifiedStoreImpl) Delete(ctx context.Context, UIDs []string, orgID int64) error { + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, orgID) + if !ok { + return nil + } + + for _, uid := range UIDs { + err = client.Delete(newCtx, uid, v1.DeleteOptions{}) + if err != nil { + return err + } + } + + return nil +} + +func (ss *FolderUnifiedStoreImpl) Update(ctx context.Context, cmd folder.UpdateFolderCommand) (*folder.Folder, error) { + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, cmd.OrgID) + if !ok { + return nil, nil + } + + obj, err := client.Get(ctx, cmd.UID, v1.GetOptions{}) + if err != nil { + return nil, err + } + + updated, err := internalfolders.LegacyUpdateCommandToUnstructured(obj, &cmd) + if err != nil { + return nil, err + } + + out, err := client.Update(ctx, updated, v1.UpdateOptions{}) + if err != nil { + return nil, err + } + + folder, _ := internalfolders.UnstructuredToLegacyFolder(*out, cmd.SignedInUser.GetOrgID()) + if err != nil { + return nil, err + } + + return folder, err +} + +// If WithFullpath is true it computes also the full path of a folder. +// The full path is a string that contains the titles of all parent folders separated by a slash. +// For example, if the folder structure is: +// +// A +// └── B +// └── C +// +// The full path of C is "A/B/C". +// The full path of B is "A/B". +// The full path of A is "A". +// If a folder contains a slash in its title, it is escaped with a backslash. +// For example, if the folder structure is: +// +// A +// └── B/C +// +// The full path of C is "A/B\/C". +func (ss *FolderUnifiedStoreImpl) Get(ctx context.Context, q folder.GetFolderQuery) (*folder.Folder, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, q.OrgID) + if !ok { + return nil, nil + } + + out, err := client.Get(newCtx, *q.UID, v1.GetOptions{}) + if err != nil { + return nil, err + } + + dashFolder, _ := internalfolders.UnstructuredToLegacyFolder(*out, q.SignedInUser.GetOrgID()) + return dashFolder, nil +} + +func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, q.OrgID) + if !ok { + return nil, nil + } + + hits := []*folder.Folder{} + + parentUid := q.UID + + for parentUid != "" { + out, err := client.Get(newCtx, parentUid, v1.GetOptions{}) + if err != nil { + return nil, err + } + + folder, _ := internalfolders.UnstructuredToLegacyFolder(*out, q.OrgID) + if err != nil { + return nil, err + } + + parentUid = folder.ParentUID + hits = append(hits, folder) + } + + if len(hits) > 0 { + return util.Reverse(hits[1:]), nil + } + + return hits, nil +} + +func (ss *FolderUnifiedStoreImpl) GetChildren(ctx context.Context, q folder.GetChildrenQuery) ([]*folder.Folder, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, q.OrgID) + if !ok { + return nil, nil + } + + out, err := client.List(newCtx, v1.ListOptions{}) + if err != nil { + return nil, err + } + + hits := make([]*folder.Folder, 0) + for _, item := range out.Items { + // convert item to legacy folder format + f, _ := internalfolders.UnstructuredToLegacyFolder(item, q.OrgID) + if f == nil { + return nil, fmt.Errorf("unable covert unstructured item to legacy folder") + } + + // it we are at root level, skip subfolder + if q.UID == "" && f.ParentUID != "" { + continue // query filter + } + // if we are at a nested folder, then skip folders that don't belong to parentUid + if q.UID != "" && !strings.EqualFold(f.ParentUID, q.UID) { + continue + } + + hits = append(hits, f) + } + + return hits, nil +} + +// TODO use a single query to get the height of a folder +func (ss *FolderUnifiedStoreImpl) GetHeight(ctx context.Context, foldrUID string, orgID int64, parentUID *string) (int, error) { + height := -1 + queue := []string{foldrUID} + for len(queue) > 0 && height <= folder.MaxNestedFolderDepth { + length := len(queue) + height++ + for i := 0; i < length; i++ { + ele := queue[0] + queue = queue[1:] + if parentUID != nil && *parentUID == ele { + return 0, folder.ErrCircularReference + } + folders, err := ss.GetChildren(ctx, folder.GetChildrenQuery{UID: ele, OrgID: orgID}) + if err != nil { + return 0, err + } + for _, f := range folders { + queue = append(queue, f.UID) + } + } + } + if height > folder.MaxNestedFolderDepth { + ss.log.Warn("folder height exceeds the maximum allowed depth, You might have a circular reference", "uid", foldrUID, "orgId", orgID, "maxDepth", folder.MaxNestedFolderDepth) + } + return height, nil +} + +// GetFolders returns org folders by their UIDs. +// If UIDs is empty, it returns all folders in the org. +// If WithFullpath is true it computes also the full path of a folder. +// The full path is a string that contains the titles of all parent folders separated by a slash. +// For example, if the folder structure is: +// +// A +// └── B +// └── C +// +// The full path of C is "A/B/C". +// The full path of B is "A/B". +// The full path of A is "A". +// If a folder contains a slash in its title, it is escaped with a backslash. +// For example, if the folder structure is: +// +// A +// └── B/C +// +// The full path of C is "A/B\/C". +// +// If FullpathUIDs is true it computes a string that contains the UIDs of all parent folders separated by slash. +// For example, if the folder structure is: +// +// A (uid: "uid1") +// └── B (uid: "uid2") +// └── C (uid: "uid3") +// +// The full path UIDs of C is "uid1/uid2/uid3". +// The full path UIDs of B is "uid1/uid2". +// The full path UIDs of A is "uid1". +func (ss *FolderUnifiedStoreImpl) GetFolders(ctx context.Context, q folder.GetFoldersFromStoreQuery) ([]*folder.Folder, error) { + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, q.OrgID) + if !ok { + return nil, nil + } + + out, err := client.List(newCtx, v1.ListOptions{}) + if err != nil { + return nil, err + } + + m := map[string]*folder.Folder{} + for _, item := range out.Items { + // convert item to legacy folder format + f, _ := internalfolders.UnstructuredToLegacyFolder(item, q.SignedInUser.GetOrgID()) + if f == nil { + return nil, fmt.Errorf("unable covert unstructured item to legacy folder") + } + + m[f.UID] = f + } + + hits := []*folder.Folder{} + + if len(q.UIDs) > 0 { + //return only the specified q.UIDs + for _, uid := range q.UIDs { + f, ok := m[uid] + if ok { + hits = append(hits, f) + } + } + + return hits, nil + } + + /* + if len(q.AncestorUIDs) > 0 { + // TODO + //return all nodes under those ancestors, requires building a tree + } + */ + + //return everything + for _, f := range m { + hits = append(hits, f) + } + + return hits, nil +} + +func (ss *FolderUnifiedStoreImpl) GetDescendants(ctx context.Context, orgID int64, ancestor_uid string) ([]*folder.Folder, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, orgID) + if !ok { + return nil, nil + } + + out, err := client.List(newCtx, v1.ListOptions{}) + if err != nil { + return nil, err + } + + nodes := map[string]*folder.Folder{} + for _, item := range out.Items { + // convert item to legacy folder format + f, _ := internalfolders.UnstructuredToLegacyFolder(item, orgID) + if f == nil { + return nil, fmt.Errorf("unable covert unstructured item to legacy folder") + } + + nodes[f.UID] = f + } + + tree := map[string]map[string]*folder.Folder{} + + for uid, f := range nodes { + parentUID := f.ParentUID + if parentUID == "" { + parentUID = "general" + } + + if tree[parentUID] == nil { + tree[parentUID] = map[string]*folder.Folder{} + } + + tree[parentUID][uid] = f + } + + descendantsMap := map[string]*folder.Folder{} + getDescendants(nodes, tree, ancestor_uid, descendantsMap) + + descendants := []*folder.Folder{} + for _, f := range descendantsMap { + descendants = append(descendants, f) + } + + return descendants, nil +} + +func getDescendants(nodes map[string]*folder.Folder, tree map[string]map[string]*folder.Folder, ancestor_uid string, descendantsMap map[string]*folder.Folder) { + for uid, _ := range tree[ancestor_uid] { + descendantsMap[uid] = nodes[uid] + getDescendants(nodes, tree, uid, descendantsMap) + } +} + +func (ss *FolderUnifiedStoreImpl) CountFolderContent(ctx context.Context, orgID int64, ancestor_uid string) (folder.DescendantCounts, error) { + // create a new context - prevents issues when the request stems from the k8s api itself + // otherwise the context goes through the handlers twice and causes issues + newCtx, cancel, err := ss.getK8sContext(ctx) + if err != nil { + return nil, err + } else if cancel != nil { + defer cancel() + } + + client, ok := ss.k8sclient.getClient(newCtx, orgID) + if !ok { + return nil, nil + } + + counts, err := client.Get(newCtx, ancestor_uid, v1.GetOptions{}, "counts") + if err != nil { + return nil, err + } + + res, err := toFolderLegacyCounts(counts) + return *res, err +} + +func toFolderLegacyCounts(u *unstructured.Unstructured) (*folder.DescendantCounts, error) { + ds, err := v0alpha1.UnstructuredToDescendantCounts(u) + if err != nil { + return nil, err + } + + var out = make(folder.DescendantCounts) + for _, v := range ds.Counts { + // if stats come from unified storage, we will use them + if v.Group != "sql-fallback" { + out[v.Resource] = v.Count + continue + } + // if stats are from single tenant DB and they are not in unified storage, we will use them + if _, ok := out[v.Resource]; !ok { + out[v.Resource] = v.Count + } + } + return &out, nil +} + +func (ss *FolderUnifiedStoreImpl) getK8sContext(ctx context.Context) (context.Context, context.CancelFunc, error) { + requester, requesterErr := identity.GetRequester(ctx) + if requesterErr != nil { + return nil, nil, requesterErr + } + + user, exists := k8sRequest.UserFrom(ctx) + if !exists { + // add in k8s user if not there yet + var ok bool + user, ok = requester.(k8sUser.Info) + if !ok { + return nil, nil, fmt.Errorf("could not convert user to k8s user") + } + } + + newCtx := k8sRequest.WithUser(context.Background(), user) + newCtx = log.WithContextualAttributes(newCtx, log.FromContext(ctx)) + // TODO: after GLSA token workflow is removed, make this return early + // and move the else below to be unconditional + if requesterErr == nil { + newCtxWithRequester := identity.WithRequester(newCtx, requester) + newCtx = newCtxWithRequester + } + + // inherit the deadline from the original context, if it exists + deadline, ok := ctx.Deadline() + if ok { + var newCancel context.CancelFunc + newCtx, newCancel = context.WithTimeout(newCtx, time.Until(deadline)) + return newCtx, newCancel, nil + } + + return newCtx, nil, nil +} diff --git a/pkg/services/folder/foldertest/foldertest.go b/pkg/services/folder/foldertest/foldertest.go index 7cfccb3b5c2..205d984d72b 100644 --- a/pkg/services/folder/foldertest/foldertest.go +++ b/pkg/services/folder/foldertest/foldertest.go @@ -22,27 +22,51 @@ var _ folder.Service = (*FakeService)(nil) func (s *FakeService) GetChildren(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { return s.ExpectedFolders, s.ExpectedError } +func (s *FakeService) GetChildrenLegacy(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.Folder, error) { + return s.ExpectedFolders, s.ExpectedError +} func (s *FakeService) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { return s.ExpectedFolders, s.ExpectedError } +func (s *FakeService) GetParentsLegacy(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) { + return s.ExpectedFolders, s.ExpectedError +} func (s *FakeService) Create(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } +func (s *FakeService) CreateLegacy(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) { + return s.ExpectedFolder, s.ExpectedError +} + func (s *FakeService) Get(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } +func (s *FakeService) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*folder.Folder, error) { + return s.ExpectedFolder, s.ExpectedError +} + func (s *FakeService) Update(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } +func (s *FakeService) UpdateLegacy(ctx context.Context, cmd *folder.UpdateFolderCommand) (*folder.Folder, error) { + return s.ExpectedFolder, s.ExpectedError +} + func (s *FakeService) Delete(ctx context.Context, cmd *folder.DeleteFolderCommand) error { return s.ExpectedError } +func (s *FakeService) DeleteLegacy(ctx context.Context, cmd *folder.DeleteFolderCommand) error { + return s.ExpectedError +} func (s *FakeService) Move(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { return s.ExpectedFolder, s.ExpectedError } +func (s *FakeService) MoveLegacy(ctx context.Context, cmd *folder.MoveFolderCommand) (*folder.Folder, error) { + return s.ExpectedFolder, s.ExpectedError +} func (s *FakeService) RegisterService(service folder.RegistryService) error { return s.ExpectedError @@ -51,7 +75,13 @@ func (s *FakeService) RegisterService(service folder.RegistryService) error { func (s *FakeService) GetDescendantCounts(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { return s.ExpectedDescendantCounts, s.ExpectedError } +func (s *FakeService) GetDescendantCountsLegacy(ctx context.Context, q *folder.GetDescendantCountsQuery) (folder.DescendantCounts, error) { + return s.ExpectedDescendantCounts, s.ExpectedError +} func (s *FakeService) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { return s.ExpectedFolders, s.ExpectedError } +func (s *FakeService) GetFoldersLegacy(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) { + return s.ExpectedFolders, s.ExpectedError +} diff --git a/pkg/services/folder/service.go b/pkg/services/folder/service.go index 50a1bec3391..656bdeeb866 100644 --- a/pkg/services/folder/service.go +++ b/pkg/services/folder/service.go @@ -5,12 +5,10 @@ import ( ) type Service interface { - // GetChildren returns an array containing all child folders. - GetChildren(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error) - // GetParents returns an array containing add parent folders if nested folders are enabled - // otherwise it returns an empty array - GetParents(ctx context.Context, q GetParentsQuery) ([]*Folder, error) + RegisterService(service RegistryService) error + Create(ctx context.Context, cmd *CreateFolderCommand) (*Folder, error) + CreateLegacy(ctx context.Context, cmd *CreateFolderCommand) (*Folder, error) // GetFolder takes a GetFolderCommand and returns a folder matching the // request. One of UID, ID or Title must be included. If multiple values @@ -20,21 +18,39 @@ type Service interface { // If ParentUID is not set then the folder will be fetched from the root level. // If WithFullpath is true it computes also the full path of a folder. Get(ctx context.Context, q *GetFolderQuery) (*Folder, error) + GetLegacy(ctx context.Context, q *GetFolderQuery) (*Folder, error) // Update is used to update a folder's UID, Title and Description. To change // a folder's parent folder, use Move. Update(ctx context.Context, cmd *UpdateFolderCommand) (*Folder, error) + UpdateLegacy(ctx context.Context, cmd *UpdateFolderCommand) (*Folder, error) + Delete(ctx context.Context, cmd *DeleteFolderCommand) error + DeleteLegacy(ctx context.Context, cmd *DeleteFolderCommand) error + // Move changes a folder's parent folder to the requested new parent. Move(ctx context.Context, cmd *MoveFolderCommand) (*Folder, error) - RegisterService(service RegistryService) error + MoveLegacy(ctx context.Context, cmd *MoveFolderCommand) (*Folder, error) + // GetFolders returns org folders that are accessible by the signed in user by their UIDs. // If WithFullpath is true it computes also the full path of a folder. // The full path is a string that contains the titles of all parent folders separated by a slash. // If a folder contains a slash in its title, it is escaped with a backslash. // If FullpathUIDs is true it computes a string that contains the UIDs of all parent folders separated by slash. GetFolders(ctx context.Context, q GetFoldersQuery) ([]*Folder, error) + GetFoldersLegacy(ctx context.Context, q GetFoldersQuery) ([]*Folder, error) + + // GetChildren returns an array containing all child folders. + GetChildren(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error) + GetChildrenLegacy(ctx context.Context, q *GetChildrenQuery) ([]*Folder, error) + + // GetParents returns an array containing add parent folders if nested folders are enabled + // otherwise it returns an empty array + GetParents(ctx context.Context, q GetParentsQuery) ([]*Folder, error) + GetParentsLegacy(ctx context.Context, q GetParentsQuery) ([]*Folder, error) + GetDescendantCounts(ctx context.Context, q *GetDescendantCountsQuery) (DescendantCounts, error) + GetDescendantCountsLegacy(ctx context.Context, q *GetDescendantCountsQuery) (DescendantCounts, error) } // FolderStore is a folder store. diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 3e24e33df33..d9b9fb833f9 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -336,7 +336,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) store := folderimpl.ProvideStore(sc.sqlStore) s := folderimpl.ProvideService(store, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sc.sqlStore, - features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) t.Logf("Creating folder with title and UID %q", title) ctx := identity.WithRequester(context.Background(), &sc.user) folder, err := s.Create(ctx, &folder.CreateFolderCommand{ @@ -473,7 +473,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo guardian.InitAccessControlGuardian(cfg, ac, dashService) fStore := folderimpl.ProvideStore(sqlStore) folderSrv := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracer), dashboardStore, folderStore, sqlStore, - features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) service := LibraryElementService{ Cfg: cfg, features: featuremgmt.WithFeatures(), diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 7d75fe779fb..b911a31be4b 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -755,7 +755,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) fStore := folderimpl.ProvideStore(sc.sqlStore) s := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sc.sqlStore, - features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) t.Logf("Creating folder with title and UID %q", title) ctx := identity.WithRequester(context.Background(), sc.user) @@ -840,7 +840,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo fStore := folderimpl.ProvideStore(sqlStore) folderService := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sqlStore, - features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, features, ac) service := LibraryPanelService{ diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 689d1ee13fc..6246e6e0e6f 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -1920,7 +1920,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) fStore := folderimpl.ProvideStore(sqlStore) folderService := folderimpl.ProvideService(fStore, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sqlStore, - featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + featuremgmt.WithFeatures(), supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) store := store.DBstore{ Logger: log, SQLStore: sqlStore, diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index 869f6cc3366..c626f854ba0 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -1604,7 +1604,7 @@ func TestProvisiongWithFullpath(t *testing.T) { features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) fStore := folderimpl.ProvideStore(sqlStore) folderService := folderimpl.ProvideService(fStore, ac, inProcBus, dashboardStore, folderStore, sqlStore, - features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) ruleService := createAlertRuleService(t, folderService) var orgID int64 = 1 diff --git a/pkg/services/ngalert/testutil/testutil.go b/pkg/services/ngalert/testutil/testutil.go index 977d2e12886..fd70d0563df 100644 --- a/pkg/services/ngalert/testutil/testutil.go +++ b/pkg/services/ngalert/testutil/testutil.go @@ -30,7 +30,7 @@ func SetupFolderService(tb testing.TB, cfg *setting.Cfg, db db.DB, dashboardStor tb.Helper() fStore := folderimpl.ProvideStore(db) return folderimpl.ProvideService(fStore, ac, bus, dashboardStore, folderStore, db, - features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) } func SetupDashboardService(tb testing.TB, sqlStore db.DB, fs *folderimpl.DashboardFolderStoreImpl, cfg *setting.Cfg) (*dashboardservice.DashboardServiceImpl, dashboards.Store) { diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index df5ecc73d9b..dfccfc3aa29 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -1395,7 +1395,7 @@ func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) { fStore := folderimpl.ProvideStore(testDB) folderPermissions := acmock.NewMockedPermissionsService() folderStore := folderimpl.ProvideDashboardFolderStore(testDB) - folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, testDB, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, testDB, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) dashboardService, err := dashsvc.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), folderPermissions, &actest.FakePermissionsService{}, ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil, nil, quotatest.New(false, nil), nil) require.NoError(t, err) diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index cba4c326784..f7f84de157e 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -494,7 +494,7 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()) folderSvc := folderimpl.ProvideService(fStore, acmock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, sqlStore, featuremgmt.WithFeatures(), - supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) _, err = dashService.ProvideDashboardServiceImpl(cfg, dashStore, folderStore, featuremgmt.WithFeatures(), acmock.NewMockedPermissionsService(), acmock.NewMockedPermissionsService(), ac, folderSvc, fStore, nil, zanzana.NewNoopClient(), nil, nil, nil, quotaService, nil) diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index cfc939bfa86..f9de60e221d 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -823,7 +823,7 @@ func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol fStore := folderimpl.ProvideStore(db) folderSvc := folderimpl.ProvideService(fStore, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, - folderimpl.ProvideDashboardFolderStore(db), db, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderimpl.ProvideDashboardFolderStore(db), db, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) // create parent folder parent, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ diff --git a/pkg/services/sqlstore/permissions/dashboards_bench_test.go b/pkg/services/sqlstore/permissions/dashboards_bench_test.go index 38d448d45b9..b58b4e5ab18 100644 --- a/pkg/services/sqlstore/permissions/dashboards_bench_test.go +++ b/pkg/services/sqlstore/permissions/dashboards_bench_test.go @@ -80,7 +80,7 @@ func setupBenchMark(b *testing.B, usr user.SignedInUser, features featuremgmt.Fe fStore := folderimpl.ProvideStore(store) folderSvc := folderimpl.ProvideService(fStore, mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(store), - store, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + store, features, supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) origNewGuardian := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true, CanSaveValue: true}) diff --git a/pkg/storage/unified/apistore/go.sum b/pkg/storage/unified/apistore/go.sum index 4dbbfe5ccce..6f8e0fb64a0 100644 --- a/pkg/storage/unified/apistore/go.sum +++ b/pkg/storage/unified/apistore/go.sum @@ -100,6 +100,8 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4 github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= @@ -559,6 +561,10 @@ github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2 h1:fV6IgVtViXcYZ4VqTAMuVBTLuGA github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2/go.mod h1:Cbh94bfL5o6mUSaHFiOkx4r4CRKlo/DJLx4dPL8XrE0= github.com/grafana/grafana-plugin-sdk-go v0.261.0 h1:pGGpPbKRWZcLxwNATEiVDhILbYGYwlWOEXFLhmUkBMo= github.com/grafana/grafana-plugin-sdk-go v0.261.0/go.mod h1:QsLK0kAbmDXuX/QncFBTETPHCzw5g9hZnzqOPkoB3Yo= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20240813192817-1b0e6b5c09b2 h1:2H9x4q53pkfUGtSNYD1qSBpNnxrFgylof/TYADb5xMI= +github.com/grafana/grafana/pkg/aggregator v0.0.0-20240813192817-1b0e6b5c09b2/go.mod h1:gBLBniiSUQvyt4LRrpIeysj8Many0DV+hdUKifRE0Ec= +github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435 h1:SNEeqY22DrGr5E9kGF1mKSqlOom14W9+b1u4XEGJowA= +github.com/grafana/grafana/pkg/semconv v0.0.0-20240808213237-f4d2e064f435/go.mod h1:8cz+z0i57IjN6MYmu/zZQdCg9CQcsnEHbaJBBEf3KQo= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= @@ -775,6 +781,8 @@ github.com/mithrandie/ternary v1.1.1 h1:k/joD6UGVYxHixYmSR8EGgDFNONBMqyD373xT4QR github.com/mithrandie/ternary v1.1.1/go.mod h1:0D9Ba3+09K2TdSZO7/bFCC0GjSXetCvYuYq0u8FY/1g= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -792,6 +800,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA= github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk= github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8= @@ -1546,6 +1556,10 @@ k8s.io/component-base v0.32.0 h1:d6cWHZkCiiep41ObYQS6IcgzOUQUNpywm39KVYaUqzU= k8s.io/component-base v0.32.0/go.mod h1:JLG2W5TUxUu5uDyKiH2R/7NnxJo1HlPoRIIbVLkK5eM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.32.0 h1:jwOfunHIrcdYl5FRcA+uUKKtg6qiqoPCwmS2T3XTYL4= +k8s.io/kms v0.32.0/go.mod h1:Bk2evz/Yvk0oVrvm4MvZbgq8BD34Ksxs2SRHn4/UiOM= +k8s.io/kube-aggregator v0.32.0 h1:5ZyMW3QwAbmkasQrROcpa5we3et938DQuyUYHeXSPao= +k8s.io/kube-aggregator v0.32.0/go.mod h1:6OKivf6Ypx44qu2v1ZUMrxH8kRp/8LKFKeJU72J18lU= k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= diff --git a/pkg/storage/unified/federated/stats_test.go b/pkg/storage/unified/federated/stats_test.go index 79350c108f7..cc24573fae0 100644 --- a/pkg/storage/unified/federated/stats_test.go +++ b/pkg/storage/unified/federated/stats_test.go @@ -51,7 +51,7 @@ func TestDirectSQLStats(t *testing.T) { fStore := folderimpl.ProvideStore(db) folderSvc := folderimpl.ProvideService(fStore, actest.FakeAccessControl{ExpectedEvaluate: true}, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderimpl.ProvideDashboardFolderStore(db), db, featuremgmt.WithFeatures(), - supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + supportbundlestest.NewFakeBundleService(), cfg, nil, tracing.InitializeTracerForTest()) // create parent folder diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index c1a0aab9046..b696918ff93 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -925,7 +925,6 @@ func TestFoldersCreateAPIEndpointK8S(t *testing.T) { } folderWithoutParentInput := "{ \"uid\": \"uid\", \"title\": \"Folder\"}" - folderWithoutUID := "{ \"title\": \"Folder without UID\"}" folderWithTitleEmpty := "{ \"title\": \"\"}" folderWithInvalidUid := "{ \"uid\": \"::::::::::::\", \"title\": \"Another folder\"}" folderWithUIDTooLong := "{ \"uid\": \"asdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnmasdfghjklqwertyuiopzxcvbnm\", \"title\": \"Third folder\"}" @@ -964,17 +963,6 @@ func TestFoldersCreateAPIEndpointK8S(t *testing.T) { expectedMessage: dashboards.ErrFolderAccessDenied.Error(), permissions: []resourcepermissions.SetResourcePermissionCommand{}, }, - { - // #TODO This test case doesn't set up the conditions it describes. We should have created a folder with the same UID before - // creating a second one and failing to do so successfully. - description: "folder creation fails given folder service error %s", - input: folderWithoutUID, - expectedCode: http.StatusConflict, - // expectedMessage: dashboards.ErrFolderWithSameUIDExists.Error(), - expectedFolderSvcError: dashboards.ErrFolderWithSameUIDExists, - createSecondRecord: true, - permissions: folderCreatePermission, - }, { description: "folder creation fails given folder service error %s", input: folderWithTitleEmpty,