Unistore: Add GetFolders endpoint backed by UnifiedStorage (#96399)

* Unistore: Add GetFolders endpoint backed by UnifiedStorage

Signed-off-by: Maicon Costa <maiconscosta@gmail.com>

---------

Signed-off-by: Maicon Costa <maiconscosta@gmail.com>
Co-authored-by: Arati R. <33031346+suntala@users.noreply.github.com>
This commit is contained in:
maicon 2024-11-22 10:38:00 -03:00 committed by GitHub
parent 706300e9b7
commit bbae396db4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 271 additions and 30 deletions

View File

@ -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 {

View File

@ -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)
}

View File

@ -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{

View File

@ -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)
}
})
}
}
}