From bbae396db4d3eaa44ab3686bfbcd158cf6290f30 Mon Sep 17 00:00:00 2001 From: maicon Date: Fri, 22 Nov 2024 10:38:00 -0300 Subject: [PATCH] Unistore: Add GetFolders endpoint backed by UnifiedStorage (#96399) * Unistore: Add GetFolders endpoint backed by UnifiedStorage Signed-off-by: Maicon Costa --------- Signed-off-by: Maicon Costa Co-authored-by: Arati R. <33031346+suntala@users.noreply.github.com> --- pkg/api/folder.go | 87 +++++++---- pkg/registry/apis/folders/register.go | 4 +- pkg/registry/apis/folders/register_test.go | 36 +++++ pkg/tests/apis/folder/folders_test.go | 174 ++++++++++++++++++++- 4 files changed, 271 insertions(+), 30 deletions(-) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 5f012b1cb30..a066fa21b50 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -3,6 +3,7 @@ package api import ( "context" "errors" + "fmt" "net/http" "strconv" "strings" @@ -45,7 +46,6 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id")) uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) - folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { @@ -64,13 +64,14 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz // Use k8s client to implement legacy API handler := newFolderK8sHandler(hs) folderRoute.Post("/", handler.createFolder) + folderRoute.Get("/", handler.getFolders) } else { folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) + folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) } // Only adding support for some routes with the k8s handler for now. Include the rest here. if false { handler := newFolderK8sHandler(hs) - folderRoute.Get("/", handler.searchFolders) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { folderUidRoute.Get("/", handler.getFolder) folderUidRoute.Delete("/", handler.deleteFolder) @@ -661,32 +662,6 @@ func newFolderK8sHandler(hs *HTTPServer) *folderK8sHandler { } } -func (fk8s *folderK8sHandler) searchFolders(c *contextmodel.ReqContext) { - client, ok := fk8s.getClient(c) - if !ok { - return // error is already sent - } - out, err := client.List(c.Req.Context(), v1.ListOptions{}) - if err != nil { - fk8s.writeError(c, err) - return - } - - query := strings.ToUpper(c.Query("query")) - folders := []folder.Folder{} - for _, item := range out.Items { - p, _ := internalfolders.UnstructuredToLegacyFolder(item, c.SignedInUser.GetOrgID()) - if p == nil { - continue - } - if query != "" && !strings.Contains(strings.ToUpper(p.Title), query) { - continue // query filter - } - folders = append(folders, *p) - } - c.JSON(http.StatusOK, folders) -} - func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { client, ok := fk8s.getClient(c) if !ok { @@ -718,6 +693,62 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { c.JSON(http.StatusOK, folderDTO) } +func (fk8s *folderK8sHandler) getFolders(c *contextmodel.ReqContext) { + // NOTE: the current implementation is temporary and it will be + // replaced by a proper indexing service/search API + // Also, the current implementation does not support pagination + + parentUid := strings.ToUpper(c.Query("parentUid")) + + client, ok := fk8s.getClient(c) + if !ok { + return // error is already sent + } + + // check that parent exists + if parentUid != "" { + _, err := client.Get(c.Req.Context(), c.Query("parentUid"), v1.GetOptions{}) + if err != nil { + fk8s.writeError(c, err) + return + } + } + + out, err := client.List(c.Req.Context(), v1.ListOptions{}) + if err != nil { + fk8s.writeError(c, err) + return + } + + hits := make([]dtos.FolderSearchHit, 0) + for _, item := range out.Items { + // convert item to legacy folder format + f, _ := internalfolders.UnstructuredToLegacyFolder(item, c.SignedInUser.GetOrgID()) + if f == nil { + fk8s.writeError(c, fmt.Errorf("unable covert unstructured item to legacy folder")) + return + } + + // it we are at root level, skip subfolder + if parentUid == "" && f.ParentUID != "" { + continue // query filter + } + // if we are at a nested folder, then skip folders that don't belong to parentUid + if parentUid != "" && strings.ToUpper(f.ParentUID) != parentUid { + continue + } + + hits = append(hits, dtos.FolderSearchHit{ + ID: f.ID, // nolint:staticcheck + UID: f.UID, + Title: f.Title, + ParentUID: f.ParentUID, + }) + } + + c.JSON(http.StatusOK, hits) +} + func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { client, ok := fk8s.getClient(c) if !ok { diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index e065b9c9c65..c46395ee271 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -189,7 +189,7 @@ func (b *FolderAPIBuilder) GetAuthorizer() authorizer.Authorizer { func authorizerFunc(ctx context.Context, attr authorizer.Attributes) (*authorizerParams, error) { verb := attr.GetVerb() name := attr.GetName() - if (!attr.IsResourceRequest()) || (name == "" && verb != utils.VerbCreate) { + if (!attr.IsResourceRequest()) || (name == "" && verb != utils.VerbCreate && verb != utils.VerbList) { return nil, errNoResource } @@ -214,6 +214,8 @@ func authorizerFunc(ctx context.Context, attr authorizer.Attributes) (*authorize fallthrough case utils.VerbDelete: eval = accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, scope) + case utils.VerbList: + eval = accesscontrol.EvalPermission(dashboards.ActionFoldersRead) default: eval = accesscontrol.EvalPermission(dashboards.ActionFoldersRead, scope) } diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 050e9a638e1..33e9344e52b 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -95,6 +95,42 @@ func TestFolderAPIBuilder_getAuthorizerFunc(t *testing.T) { eval: "folders:create", }, }, + { + name: "user with read permissions should be able to list folders", + input: input{ + user: &user.SignedInUser{ + UserID: 1, + OrgID: orgID, + Name: "123", + Permissions: map[int64]map[string][]string{ + orgID: {dashboards.ActionFoldersRead: {dashboards.ScopeFoldersAll}}, + }, + }, + verb: string(utils.VerbList), + }, + expect: expect{ + eval: "folders:read", + allow: true, + }, + }, + { + name: "user without read permissions should not be able to list folders", + input: input{ + user: &user.SignedInUser{ + UserID: 1, + OrgID: orgID, + Name: "123", + Permissions: map[int64]map[string][]string{ + orgID: {}, + }, + }, + verb: string(utils.VerbList), + }, + expect: expect{ + eval: "folders:read", + allow: false, + }, + }, } b := &FolderAPIBuilder{ diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 9621952d18b..511d4b44e97 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -412,7 +413,7 @@ func doFolderTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper require.JSONEq(t, expectedResult, client.SanitizeJSON(found)) }) - t.Run("Do CRUD (just CR for now) via k8s (and check that legacy api still works)", func(t *testing.T) { + t.Run("Do CRUD (just CR+List for now) via k8s (and check that legacy api still works)", func(t *testing.T) { client := helper.GetResourceClient(apis.ResourceClientArgs{ // #TODO: figure out permissions topic User: helper.Org1.Admin, @@ -462,6 +463,15 @@ func doFolderTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper require.Equal(t, "New description", description) // #TODO figure out why this breaks just for MySQL integration tests // require.Less(t, first.GetResourceVersion(), updated.GetResourceVersion()) + + // ensure that we get 4 items when listing via k8s + l, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.NotNil(t, l) + folders, err := meta.ExtractList(l) + require.NoError(t, err) + require.NotNil(t, folders) + require.Equal(t, len(folders), 4) }) return helper } @@ -926,3 +936,165 @@ func testDescription(description string, expectedErr error) string { return description } } + +// There are no counterpart of TestFoldersGetAPIEndpointK8S in pkg/api/folder_test.go +func TestFoldersGetAPIEndpointK8S(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + type testCase struct { + description string + expectedCode int + params string + createFolders []string + expectedOutput []dtos.FolderSearchHit + permissions []resourcepermissions.SetResourcePermissionCommand + requestToAnotherOrg bool + } + + folderReadAndCreatePermission := []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{"folders:create", "folders:read"}, + Resource: "folders", + ResourceAttribute: "uid", + ResourceID: "*", + }, + } + + folder1 := "{ \"uid\": \"foo\", \"title\": \"Folder 1\"}" + folder2 := "{ \"uid\": \"bar\", \"title\": \"Folder 2\", \"parentUid\": \"foo\"}" + folder3 := "{ \"uid\": \"qux\", \"title\": \"Folder 3\"}" + + tcs := []testCase{ + { + description: "listing folders at root level succeeds", + createFolders: []string{ + folder1, + folder2, + folder3, + }, + expectedCode: http.StatusOK, + expectedOutput: []dtos.FolderSearchHit{ + dtos.FolderSearchHit{UID: "foo", Title: "Folder 1"}, + dtos.FolderSearchHit{UID: "qux", Title: "Folder 3"}, + }, + permissions: folderReadAndCreatePermission, + }, + { + description: "listing subfolders succeeds", + createFolders: []string{ + folder1, + folder2, + folder3, + }, + params: "?parentUid=foo", + expectedCode: http.StatusOK, + expectedOutput: []dtos.FolderSearchHit{ + dtos.FolderSearchHit{UID: "bar", Title: "Folder 2", ParentUID: "foo"}, + }, + permissions: folderReadAndCreatePermission, + }, + { + description: "listing subfolders for a parent that does not exists", + createFolders: []string{ + folder1, + folder2, + folder3, + }, + params: "?parentUid=notexists", + expectedCode: http.StatusNotFound, + expectedOutput: []dtos.FolderSearchHit{}, + permissions: folderReadAndCreatePermission, + }, + { + description: "listing folders at root level fails without the right permissions", + createFolders: []string{ + folder1, + folder2, + folder3, + }, + params: "?parentUid=notfound", + expectedCode: http.StatusForbidden, + expectedOutput: []dtos.FolderSearchHit{}, + permissions: folderReadAndCreatePermission, + requestToAnotherOrg: true, + }, + } + + // test on all dualwriter modes + for mode := 1; mode <= 4; mode++ { + for _, tc := range tcs { + t.Run(fmt.Sprintf("Mode: %d, %s", mode, tc.description), func(t *testing.T) { + modeDw := grafanarest.DualWriterMode(mode) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + folderv0alpha1.RESOURCEGROUP: { + DualWriterMode: modeDw, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerTestingWithExperimentalAPIs, + featuremgmt.FlagNestedFolders, + featuremgmt.FlagKubernetesFolders, + }, + }) + + userTest := helper.CreateUser("user", apis.Org1, org.RoleNone, tc.permissions) + + for _, f := range tc.createFolders { + client := helper.GetResourceClient(apis.ResourceClientArgs{ + User: userTest, + GVR: gvr, + }) + create2 := apis.DoRequest(helper, apis.RequestParams{ + User: client.Args.User, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(f), + }, &folder.Folder{}) + require.NotEmpty(t, create2.Response) + require.Equal(t, http.StatusOK, create2.Response.StatusCode) + } + + addr := helper.GetEnv().Server.HTTPServer.Listener.Addr() + login := userTest.Identity.GetLogin() + baseUrl := fmt.Sprintf("http://%s:%s@%s", login, user.Password("user"), addr) + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf( + "%s%s", + baseUrl, + fmt.Sprintf("/api/folders%s", tc.params), + ), nil) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + if tc.requestToAnotherOrg { + req.Header.Set("x-grafana-org-id", "2") + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, tc.expectedCode, resp.StatusCode) + + if tc.expectedCode == http.StatusOK { + list := []dtos.FolderSearchHit{} + err = json.NewDecoder(resp.Body).Decode(&list) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // ignore IDs + for i := 0; i < len(list); i++ { + list[i].ID = 0 + } + + require.ElementsMatch(t, tc.expectedOutput, list) + } + }) + } + } +}