diff --git a/pkg/api/dtos/folder.go b/pkg/api/dtos/folder.go index f6576a053b9..d9959caf7c7 100644 --- a/pkg/api/dtos/folder.go +++ b/pkg/api/dtos/folder.go @@ -1,26 +1,32 @@ package dtos -import "time" +import ( + "time" + + "github.com/grafana/grafana/pkg/services/accesscontrol" +) type Folder struct { - Id int64 `json:"id"` - Uid string `json:"uid"` - Title string `json:"title"` - Url string `json:"url"` - HasAcl bool `json:"hasAcl"` - CanSave bool `json:"canSave"` - CanEdit bool `json:"canEdit"` - CanAdmin bool `json:"canAdmin"` - CanDelete bool `json:"canDelete"` - CreatedBy string `json:"createdBy"` - Created time.Time `json:"created"` - UpdatedBy string `json:"updatedBy"` - Updated time.Time `json:"updated"` - Version int `json:"version"` + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` + Url string `json:"url"` + HasAcl bool `json:"hasAcl"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanDelete bool `json:"canDelete"` + CreatedBy string `json:"createdBy"` + Created time.Time `json:"created"` + UpdatedBy string `json:"updatedBy"` + Updated time.Time `json:"updated"` + Version int `json:"version"` + AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"` } type FolderSearchHit struct { - Id int64 `json:"id"` - Uid string `json:"uid"` - Title string `json:"title"` + Id int64 `json:"id"` + Uid string `json:"uid"` + Title string `json:"title"` + AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"` } diff --git a/pkg/api/folder.go b/pkg/api/folder.go index cb45ad640ab..75ba5f1028a 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -1,7 +1,6 @@ package api import ( - "context" "errors" "fmt" "net/http" @@ -11,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/store" @@ -25,9 +25,10 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { return apierrors.ToFolderErrorResponse(err) } + uids := make(map[string]bool, len(folders)) result := make([]dtos.FolderSearchHit, 0) - for _, f := range folders { + uids[f.Uid] = true result = append(result, dtos.FolderSearchHit{ Id: f.Id, Uid: f.Uid, @@ -35,6 +36,13 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response { }) } + metadata := hs.getMultiAccessControlMetadata(c, c.OrgId, dashboards.ScopeFoldersPrefix, uids) + if len(metadata) > 0 { + for i := range result { + result[i].AccessControl = metadata[result[i].Uid] + } + } + return response.JSON(http.StatusOK, result) } @@ -45,7 +53,7 @@ func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder)) + return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder)) } func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { @@ -59,7 +67,7 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder)) + return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder)) } func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { @@ -81,7 +89,7 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder)) + return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder)) } func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { @@ -103,7 +111,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { } g := guardian.New(c.Req.Context(), cmd.Result.Id, c.OrgId, c.SignedInUser) - return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, cmd.Result)) + return response.JSON(http.StatusOK, hs.toFolderDto(c, g, cmd.Result)) } func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed @@ -136,7 +144,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // }) } -func (hs *HTTPServer) toFolderDto(ctx context.Context, g guardian.DashboardGuardian, folder *models.Folder) dtos.Folder { +func (hs *HTTPServer) toFolderDto(c *models.ReqContext, g guardian.DashboardGuardian, folder *models.Folder) dtos.Folder { canEdit, _ := g.CanEdit() canSave, _ := g.CanSave() canAdmin, _ := g.CanAdmin() @@ -145,26 +153,27 @@ func (hs *HTTPServer) toFolderDto(ctx context.Context, g guardian.DashboardGuard // Finding creator and last updater of the folder updater, creator := anonString, anonString if folder.CreatedBy > 0 { - creator = hs.getUserLogin(ctx, folder.CreatedBy) + creator = hs.getUserLogin(c.Req.Context(), folder.CreatedBy) } if folder.UpdatedBy > 0 { - updater = hs.getUserLogin(ctx, folder.UpdatedBy) + updater = hs.getUserLogin(c.Req.Context(), folder.UpdatedBy) } return dtos.Folder{ - Id: folder.Id, - Uid: folder.Uid, - Title: folder.Title, - Url: folder.Url, - HasAcl: folder.HasAcl, - CanSave: canSave, - CanEdit: canEdit, - CanAdmin: canAdmin, - CanDelete: canDelete, - CreatedBy: creator, - Created: folder.Created, - UpdatedBy: updater, - Updated: folder.Updated, - Version: folder.Version, + Id: folder.Id, + Uid: folder.Uid, + Title: folder.Title, + Url: folder.Url, + HasAcl: folder.HasAcl, + CanSave: canSave, + CanEdit: canEdit, + CanAdmin: canAdmin, + CanDelete: canDelete, + CreatedBy: creator, + Created: folder.Created, + UpdatedBy: updater, + Updated: folder.Updated, + Version: folder.Version, + AccessControl: hs.getAccessControlMetadata(c, c.OrgId, dashboards.ScopeFoldersPrefix, folder.Uid), } } diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index d62b4ad68c5..23f08eca99d 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -4,15 +4,19 @@ import ( "context" "encoding/json" "fmt" + "net/http" "testing" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web/webtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -130,16 +134,106 @@ func TestFoldersAPIEndpoint(t *testing.T) { }) } +func TestHTTPServer_FolderMetadata(t *testing.T) { + setUpRBACGuardian(t) + folderService := dashboards.NewFakeFolderService(t) + server := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.folderService = folderService + hs.AccessControl = acmock.New() + }) + + t.Run("Should attach access control metadata to multiple folders", func(t *testing.T) { + folderService.On("GetFolders", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*models.Folder{ + {Uid: "1"}, + {Uid: "2"}, + {Uid: "3"}, + }, nil) + + req := server.NewGetRequest("/api/folders?accesscontrol=true") + webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, + {Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("2")}, + }), + }}) + + res, err := server.Send(req) + require.NoError(t, err) + defer func() { require.NoError(t, res.Body.Close()) }() + assert.Equal(t, http.StatusOK, res.StatusCode) + + body := []dtos.FolderSearchHit{} + require.NoError(t, json.NewDecoder(res.Body).Decode(&body)) + + for _, f := range body { + assert.True(t, f.AccessControl[dashboards.ActionFoldersRead]) + if f.Uid == "2" { + assert.True(t, f.AccessControl[dashboards.ActionFoldersWrite]) + } else { + assert.False(t, f.AccessControl[dashboards.ActionFoldersWrite]) + } + } + }) + + t.Run("Should attach access control metadata to folder response", func(t *testing.T) { + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&models.Folder{Uid: "folderUid"}, nil) + + req := server.NewGetRequest("/api/folders/folderUid?accesscontrol=true") + webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, + {Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("folderUid")}, + }), + }}) + + res, err := server.Send(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + defer func() { require.NoError(t, res.Body.Close()) }() + + body := dtos.Folder{} + require.NoError(t, json.NewDecoder(res.Body).Decode(&body)) + + assert.True(t, body.AccessControl[dashboards.ActionFoldersRead]) + assert.True(t, body.AccessControl[dashboards.ActionFoldersWrite]) + }) + + t.Run("Should attach access control metadata to folder response", func(t *testing.T) { + folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&models.Folder{Uid: "folderUid"}, nil) + + req := server.NewGetRequest("/api/folders/folderUid") + webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, + {Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("folderUid")}, + }), + }}) + + res, err := server.Send(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + defer func() { require.NoError(t, res.Body.Close()) }() + + body := dtos.Folder{} + require.NoError(t, json.NewDecoder(res.Body).Decode(&body)) + + assert.False(t, body.AccessControl[dashboards.ActionFoldersRead]) + assert.False(t, body.AccessControl[dashboards.ActionFoldersWrite]) + }) +} + func callCreateFolder(sc *scenarioContext) { sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() } func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService, cmd models.CreateFolderCommand, fn scenarioFunc) { + setUpRBACGuardian(t) t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := HTTPServer{ - Cfg: setting.NewCfg(), + AccessControl: acmock.New(), folderService: folderService, + Cfg: setting.NewCfg(), Features: featuremgmt.WithFeatures(), } @@ -165,9 +259,11 @@ func callUpdateFolder(sc *scenarioContext) { func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService, cmd models.UpdateFolderCommand, fn scenarioFunc) { + setUpRBACGuardian(t) t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := HTTPServer{ Cfg: setting.NewCfg(), + AccessControl: acmock.New(), folderService: folderService, } diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index dec16440142..49085a81083 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -88,10 +88,18 @@ func (m *Mock) Evaluate(ctx context.Context, user *models.SignedInUser, evaluato if m.EvaluateFunc != nil { return m.EvaluateFunc(ctx, user, evaluator) } - // Otherwise perform an actual evaluation of the permissions - permissions, err := m.GetUserPermissions(ctx, user, accesscontrol.Options{ReloadCache: false}) - if err != nil { - return false, err + + var permissions map[string][]string + if user.Permissions != nil && user.Permissions[user.OrgId] != nil { + permissions = user.Permissions[user.OrgId] + } + + if permissions == nil { + userPermissions, err := m.GetUserPermissions(ctx, user, accesscontrol.Options{ReloadCache: true}) + if err != nil { + return false, err + } + permissions = accesscontrol.GroupScopesByAction(userPermissions) } attributeMutator := m.scopeResolvers.GetScopeAttributeMutator(user.OrgId) @@ -99,7 +107,7 @@ func (m *Mock) Evaluate(ctx context.Context, user *models.SignedInUser, evaluato if err != nil { return false, err } - return resolvedEvaluator.Evaluate(accesscontrol.GroupScopesByAction(permissions)), nil + return resolvedEvaluator.Evaluate(permissions), nil } // GetUserPermissions returns user permissions. diff --git a/public/app/types/folders.ts b/public/app/types/folders.ts index 9577b4a6597..21a1fb2da78 100644 --- a/public/app/types/folders.ts +++ b/public/app/types/folders.ts @@ -1,6 +1,8 @@ +import { WithAccessControlMetadata } from '@grafana/data'; + import { DashboardAcl } from './acl'; -export interface FolderDTO { +export interface FolderDTO extends WithAccessControlMetadata { id: number; uid: string; title: string;