grafana/pkg/api/folder.go
Sofia Papagiannaki 03a626f1d6
Search: Fix empty folder details for nested folder items (#76504)
* Introduce dashboard.folder_uid column

* Add data migration

* Search: Fix empty folder details for nested folders

* Set `dashboard.folder_uid` and update tests

* Add unique index

* lint

Ignore cyclomatic complexity of func
`(*DashboardServiceImpl).BuildSaveDashboardCommand

* Fix search by folder UID
2023-10-24 10:04:45 +03:00

598 lines
18 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"strconv"
"github.com/grafana/grafana/pkg/api/apierrors"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"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"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
const REDACTED = "redacted"
// swagger:route GET /folders folders getFolders
//
// Get all folders.
//
// Returns all folders that the authenticated user has permission to view.
// If nested folders are enabled, it expects an additional query parameter with the parent folder UID
// and returns the immediate subfolders that the authenticated user has permission to view.
// If the parameter is not supplied then it returns immediate subfolders under the root
// that the authenticated user has permission to view.
//
// Responses:
// 200: getFoldersResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (hs *HTTPServer) GetFolders(c *contextmodel.ReqContext) response.Response {
var folders []*folder.Folder
var err error
if hs.Features.IsEnabled(featuremgmt.FlagNestedFolders) {
folders, err = hs.folderService.GetChildren(c.Req.Context(), &folder.GetChildrenQuery{
OrgID: c.SignedInUser.GetOrgID(),
Limit: c.QueryInt64("limit"),
Page: c.QueryInt64("page"),
UID: c.Query("parentUid"),
SignedInUser: c.SignedInUser,
})
} else {
folders, err = hs.searchFolders(c)
}
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
result := make([]dtos.FolderSearchHit, 0)
for _, f := range folders {
result = append(result, dtos.FolderSearchHit{
Id: f.ID,
Uid: f.UID,
Title: f.Title,
ParentUID: f.ParentUID,
})
}
return response.JSON(http.StatusOK, result)
}
// swagger:route GET /folders/{folder_uid} folders getFolderByUID
//
// Get folder by uid.
//
// Responses:
// 200: folderResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) GetFolderByUID(c *contextmodel.ReqContext) response.Response {
uid := web.Params(c.Req)[":uid"]
folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{OrgID: c.SignedInUser.GetOrgID(), UID: &uid, SignedInUser: c.SignedInUser})
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
folderDTO, err := hs.newToFolderDto(c, folder)
if err != nil {
return response.Err(err)
}
return response.JSON(http.StatusOK, folderDTO)
}
// swagger:route GET /folders/id/{folder_id} folders getFolderByID
//
// Get folder by id.
//
// Returns the folder identified by id. This is deprecated.
// Please refer to [updated API](#/folders/getFolderByUID) instead
//
// Deprecated: true
//
// Responses:
// 200: folderResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) GetFolderByID(c *contextmodel.ReqContext) response.Response {
id, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "id is invalid", err)
}
folder, err := hs.folderService.Get(c.Req.Context(), &folder.GetFolderQuery{ID: &id, OrgID: c.SignedInUser.GetOrgID(), SignedInUser: c.SignedInUser})
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
folderDTO, err := hs.newToFolderDto(c, folder)
if err != nil {
return response.Err(err)
}
return response.JSON(http.StatusOK, folderDTO)
}
// swagger:route POST /folders folders createFolder
//
// Create folder.
//
// If nested folders are enabled then it additionally expects the parent folder UID.
//
// Responses:
// 200: folderResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
// 409: conflictError
// 500: internalServerError
func (hs *HTTPServer) CreateFolder(c *contextmodel.ReqContext) response.Response {
cmd := folder.CreateFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
cmd.OrgID = c.SignedInUser.GetOrgID()
cmd.SignedInUser = c.SignedInUser
folder, err := hs.folderService.Create(c.Req.Context(), &cmd)
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
if err := hs.setDefaultFolderPermissions(c.Req.Context(), cmd.OrgID, cmd.SignedInUser, folder); err != nil {
hs.log.Error("Could not set the default folder permissions", "folder", folder.Title, "user", cmd.SignedInUser, "error", err)
}
// Clear permission cache for the user who's created the folder, so that new permissions are fetched for their next call
// Required for cases when caller wants to immediately interact with the newly created object
hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
folderDTO, err := hs.newToFolderDto(c, folder)
if err != nil {
return response.Err(err)
}
// TODO set ParentUID if nested folders are enabled
return response.JSON(http.StatusOK, folderDTO)
}
func (hs *HTTPServer) setDefaultFolderPermissions(ctx context.Context, orgID int64, user identity.Requester, folder *folder.Folder) error {
var permissions []accesscontrol.SetResourcePermissionCommand
var userID int64
namespace, id := user.GetNamespacedID()
if namespace == identity.NamespaceUser {
var errID error
userID, errID = identity.IntIdentifier(namespace, id)
if errID != nil {
return errID
}
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{
UserID: userID, Permission: dashboards.PERMISSION_ADMIN.String(),
})
}
isNested := folder.ParentUID != ""
if !isNested || !hs.Features.IsEnabled(featuremgmt.FlagNestedFolders) {
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
{BuiltinRole: string(org.RoleEditor), Permission: dashboards.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboards.PERMISSION_VIEW.String()},
}...)
}
_, err := hs.folderPermissionsService.SetPermissions(ctx, orgID, folder.UID, permissions...)
return err
}
// swagger:route POST /folders/{folder_uid}/move folders moveFolder
//
// Move folder.
//
// Responses:
// 200: folderResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) MoveFolder(c *contextmodel.ReqContext) response.Response {
if hs.Features.IsEnabled(featuremgmt.FlagNestedFolders) {
cmd := folder.MoveFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
var err error
cmd.OrgID = c.SignedInUser.GetOrgID()
cmd.UID = web.Params(c.Req)[":uid"]
cmd.SignedInUser = c.SignedInUser
theFolder, err := hs.folderService.Move(c.Req.Context(), &cmd)
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "move folder failed", err)
}
folderDTO, err := hs.newToFolderDto(c, theFolder)
if err != nil {
return response.Err(err)
}
return response.JSON(http.StatusOK, folderDTO)
}
result := map[string]string{}
result["message"] = "To use this service, you need to activate nested folder feature."
return response.JSON(http.StatusNotFound, result)
}
// swagger:route PUT /folders/{folder_uid} folders updateFolder
//
// Update folder.
//
// Responses:
// 200: folderResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 409: conflictError
// 500: internalServerError
func (hs *HTTPServer) UpdateFolder(c *contextmodel.ReqContext) response.Response {
cmd := folder.UpdateFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
cmd.OrgID = c.SignedInUser.GetOrgID()
cmd.UID = web.Params(c.Req)[":uid"]
cmd.SignedInUser = c.SignedInUser
result, err := hs.folderService.Update(c.Req.Context(), &cmd)
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
folderDTO, err := hs.newToFolderDto(c, result)
if err != nil {
return response.Err(err)
}
return response.JSON(http.StatusOK, folderDTO)
}
// swagger:route DELETE /folders/{folder_uid} folders deleteFolder
//
// Delete folder.
//
// Deletes an existing folder identified by UID along with all dashboards (and their alerts) stored in the folder. This operation cannot be reverted.
// If nested folders are enabled then it also deletes all the subfolders.
//
// Responses:
// 200: deleteFolderResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
if err != nil {
if errors.Is(err, model.ErrFolderHasConnectedLibraryElements) {
return response.Error(403, "Folder could not be deleted because it contains library elements in use", err)
}
return apierrors.ToFolderErrorResponse(err)
}
/* TODO: after a decision regarding folder deletion permissions has been made
(https://github.com/grafana/grafana-enterprise/issues/5144),
remove the previous call to hs.LibraryElementService.DeleteLibraryElementsInFolder
and remove "user" from the signature of DeleteInFolder in the folder RegistryService.
Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903
*/
uid := web.Params(c.Req)[":uid"]
err = hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.SignedInUser.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser})
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
return response.JSON(http.StatusOK, util.DynMap{
"message": "Folder deleted",
})
}
// swagger:route GET /folders/{folder_uid}/counts folders getFolderDescendantCounts
//
// Gets the count of each descendant of a folder by kind. The folder is identified by UID.
//
// Responses:
// 200: getFolderDescendantCountsResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) GetFolderDescendantCounts(c *contextmodel.ReqContext) response.Response {
uid := web.Params(c.Req)[":uid"]
counts, err := hs.folderService.GetDescendantCounts(c.Req.Context(), &folder.GetDescendantCountsQuery{OrgID: c.SignedInUser.GetOrgID(), UID: &uid, SignedInUser: c.SignedInUser})
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
return response.JSON(http.StatusOK, counts)
}
func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, f *folder.Folder) (dtos.Folder, error) {
ctx := c.Req.Context()
toDTO := func(f *folder.Folder, checkCanView bool) (dtos.Folder, error) {
g, err := guardian.NewByFolder(c.Req.Context(), f, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return dtos.Folder{}, err
}
canEdit, _ := g.CanEdit()
canSave, _ := g.CanSave()
canAdmin, _ := g.CanAdmin()
canDelete, _ := g.CanDelete()
// Finding creator and last updater of the folder
updater, creator := anonString, anonString
if f.CreatedBy > 0 {
creator = hs.getUserLogin(ctx, f.CreatedBy)
}
if f.UpdatedBy > 0 {
updater = hs.getUserLogin(ctx, f.UpdatedBy)
}
acMetadata, _ := hs.getFolderACMetadata(c, f)
if checkCanView {
canView, _ := g.CanView()
if !canView {
return dtos.Folder{
Uid: REDACTED,
Title: REDACTED,
}, nil
}
}
return dtos.Folder{
Id: f.ID,
Uid: f.UID,
Title: f.Title,
Url: f.URL,
HasACL: f.HasACL,
CanSave: canSave,
CanEdit: canEdit,
CanAdmin: canAdmin,
CanDelete: canDelete,
CreatedBy: creator,
Created: f.Created,
UpdatedBy: updater,
Updated: f.Updated,
Version: f.Version,
AccessControl: acMetadata,
ParentUID: f.ParentUID,
}, nil
}
// no need to check view permission for the starting folder since it's already checked by the callers
folderDTO, err := toDTO(f, false)
if err != nil {
return dtos.Folder{}, err
}
if !hs.Features.IsEnabled(featuremgmt.FlagNestedFolders) {
return folderDTO, nil
}
parents, err := hs.folderService.GetParents(ctx, folder.GetParentsQuery{UID: f.UID, OrgID: f.OrgID})
if err != nil {
// log the error instead of failing
hs.log.Error("failed to fetch folder parents", "folder", f.UID, "org", f.OrgID, "error", err)
}
folderDTO.Parents = make([]dtos.Folder, 0, len(parents))
for _, f := range parents {
DTO, err := toDTO(f, true)
if err != nil {
hs.log.Error("failed to convert folder to DTO", "folder", f.UID, "org", f.OrgID, "error", err)
continue
}
folderDTO.Parents = append(folderDTO.Parents, DTO)
}
return folderDTO, nil
}
func (hs *HTTPServer) getFolderACMetadata(c *contextmodel.ReqContext, f *folder.Folder) (accesscontrol.Metadata, error) {
if !c.QueryBool("accesscontrol") {
return nil, nil
}
parents, err := hs.folderService.GetParents(c.Req.Context(), folder.GetParentsQuery{UID: f.UID, OrgID: c.SignedInUser.GetOrgID()})
if err != nil {
return nil, err
}
folderIDs := map[string]bool{f.UID: true}
for _, p := range parents {
folderIDs[p.UID] = true
}
allMetadata := hs.getMultiAccessControlMetadata(c, dashboards.ScopeFoldersPrefix, folderIDs)
metadata := allMetadata[f.UID]
// Flatten metadata - if any parent has a permission, the child folder inherits it
for _, md := range allMetadata {
for action := range md {
metadata[action] = true
}
}
return metadata, nil
}
func (hs *HTTPServer) searchFolders(c *contextmodel.ReqContext) ([]*folder.Folder, error) {
searchQuery := search.Query{
SignedInUser: c.SignedInUser,
DashboardIds: make([]int64, 0),
FolderIds: make([]int64, 0),
Limit: c.QueryInt64("limit"),
OrgId: c.SignedInUser.GetOrgID(),
Type: "dash-folder",
Permission: dashboards.PERMISSION_VIEW,
Page: c.QueryInt64("page"),
}
hits, err := hs.SearchService.SearchHandler(c.Req.Context(), &searchQuery)
if err != nil {
return nil, err
}
folders := make([]*folder.Folder, 0)
for _, hit := range hits {
folders = append(folders, &folder.Folder{
ID: hit.ID,
UID: hit.UID,
Title: hit.Title,
})
}
return folders, nil
}
// swagger:parameters getFolders
type GetFoldersParams struct {
// Limit the maximum number of folders to return
// in:query
// required:false
// default:1000
Limit int64 `json:"limit"`
// Page index for starting fetching folders
// in:query
// required:false
// default:1
Page int64 `json:"page"`
// The parent folder UID
// in:query
// required:false
ParentUID string `json:"parentUid"`
}
// swagger:parameters getFolderByUID
type GetFolderByUIDParams struct {
// in:path
// required:true
FolderUID string `json:"folder_uid"`
}
// swagger:parameters updateFolder
type UpdateFolderParams struct {
// in:path
// required:true
FolderUID string `json:"folder_uid"`
// To change the unique identifier (uid), provide another one.
// To overwrite an existing folder with newer version, set `overwrite` to `true`.
// Provide the current version to safelly update the folder: if the provided version differs from the stored one the request will fail, unless `overwrite` is `true`.
//
// in:body
// required:true
Body folder.UpdateFolderCommand `json:"body"`
}
// swagger:parameters getFolderByID
type GetFolderByIDParams struct {
// in:path
// required:true
FolderID int64 `json:"folder_id"`
}
// swagger:parameters createFolder
type CreateFolderParams struct {
// in:body
// required:true
Body folder.CreateFolderCommand `json:"body"`
}
// swagger:parameters moveFolder
type MoveFolderParams struct {
// in:path
// required:true
FolderUID string `json:"folder_uid"`
// in:body
// required:true
Body folder.MoveFolderCommand `json:"body"`
}
// swagger:parameters deleteFolder
type DeleteFolderParams struct {
// in:path
// required:true
FolderUID string `json:"folder_uid"`
// If `true` any Grafana 8 Alerts under this folder will be deleted.
// Set to `false` so that the request will fail if the folder contains any Grafana 8 Alerts.
// in:query
// required:false
// default:false
ForceDeleteRules bool `json:"forceDeleteRules"`
}
// swagger:response getFoldersResponse
type GetFoldersResponse struct {
// The response message
// in: body
Body []dtos.FolderSearchHit `json:"body"`
}
// swagger:response folderResponse
type FolderResponse struct {
// The response message
// in: body
Body dtos.Folder `json:"body"`
}
// swagger:response deleteFolderResponse
type DeleteFolderResponse struct {
// The response message
// in: body
Body struct {
// ID Identifier of the deleted folder.
// required: true
// example: 65
ID int64 `json:"id"`
// Title of the deleted folder.
// required: true
// example: My Folder
Title string `json:"title"`
// Message Message of the deleted folder.
// required: true
// example: Folder My Folder deleted
Message string `json:"message"`
} `json:"body"`
}
// swagger:parameters getFolderDescendantCounts
type GetFolderDescendantCountsParams struct {
// in:path
// required:true
FolderUID string `json:"folder_uid"`
}
// swagger:response getFolderDescendantCountsResponse
type GetFolderDescendantCountsResponse struct {
// The response message
// in: body
Body folder.DescendantCounts `json:"body"`
}