The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/pkg/services/folder/folderimpl/folder_unifiedstorage.go

856 lines
28 KiB

package folderimpl
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"k8s.io/apimachinery/pkg/selection"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/util"
)
const folderSearchLimit = 100000
const folderListLimit = 100000
func (s *Service) getFoldersFromApiServer(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
ctx, span := s.tracer.Start(ctx, "folder.getFoldersFromApiServer")
defer span.End()
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) {
ctx, span := s.tracer.Start(ctx, "folder.getFromApiServer")
defer span.End()
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
}
ctx = identity.WithRequester(ctx, q.SignedInUser)
var dashFolder *folder.Folder
var err error
switch {
case q.UID != nil && *q.UID != "":
dashFolder, err = s.unifiedStore.Get(ctx, *q)
if err != nil {
return nil, toFolderError(err)
}
// nolint:staticcheck
case q.ID != nil && *q.ID != 0:
dashFolder, err = s.getFolderByIDFromApiServer(ctx, *q.ID, q.OrgID)
if err != nil {
return nil, toFolderError(err)
}
case q.Title != nil && *q.Title != "":
dashFolder, err = s.getFolderByTitleFromApiServer(ctx, q.OrgID, *q.Title, q.ParentUID)
if err != nil {
return nil, toFolderError(err)
}
default:
return &folder.GeneralFolder, nil
}
if dashFolder.IsGeneral() {
return dashFolder, nil
}
evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(dashFolder.UID))
if canView, err := s.accessControl.Evaluate(ctx, q.SignedInUser, evaluator); 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
if q.WithFullpath || q.WithFullpathUIDs {
f, err = s.setFullpath(ctx, f, false)
if err != nil {
return nil, err
}
}
return f, err
}
// searchFoldesFromApiServer uses the search grpc connection to search folders and returns the hit list
func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.SearchFoldersQuery) (model.HitList, error) {
ctx, span := s.tracer.Start(ctx, "folder.searchFoldersFromApiServer")
defer span.End()
if query.OrgID == 0 {
requester, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
query.OrgID = requester.GetOrgID()
}
request := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Namespace: s.k8sclient.GetNamespace(query.OrgID),
Group: folderv1.FolderResourceInfo.GroupVersionResource().Group,
Resource: folderv1.FolderResourceInfo.GroupVersionResource().Resource,
},
Fields: []*resourcepb.Requirement{},
Labels: []*resourcepb.Requirement{},
},
Limit: folderSearchLimit}
if len(query.UIDs) > 0 {
request.Options.Fields = []*resourcepb.Requirement{{
Key: resource.SEARCH_FIELD_NAME,
Operator: string(selection.In),
Values: query.UIDs,
}}
} else if len(query.IDs) > 0 {
values := make([]string, len(query.IDs))
for i, id := range query.IDs {
values[i] = strconv.FormatInt(id, 10)
}
request.Options.Labels = append(request.Options.Labels, &resourcepb.Requirement{
Key: utils.LabelKeyDeprecatedInternalID, // nolint:staticcheck
Operator: string(selection.In),
Values: values,
})
}
if query.Title != "" {
// allow wildcard search
request.Query = "*" + strings.ToLower(query.Title) + "*"
// or perform exact match if requested
if query.TitleExactMatch {
request.Query = query.Title
}
// if using query, you need to specify the fields you want
request.Fields = dashboardsearch.IncludeFields
}
if query.Limit > 0 {
request.Limit = query.Limit
}
parsedResults, err := dashboardsearch.SearchAll(ctx, query.OrgID, request, s.k8sclient.Search)
if err != nil {
return nil, err
}
hitList := make([]*model.Hit, len(parsedResults.Hits))
for i, item := range parsedResults.Hits {
slug := slugify.Slugify(item.Title)
hitList[i] = &model.Hit{
ID: item.Field.GetNestedInt64(resource.SEARCH_FIELD_LEGACY_ID),
UID: item.Name,
OrgID: query.OrgID,
Title: item.Title,
URI: "db/" + slug,
URL: dashboards.GetFolderURL(item.Name, slug),
Type: model.DashHitFolder,
FolderUID: item.Folder,
Description: item.Description,
}
}
return hitList, nil
}
func (s *Service) getFolderByIDFromApiServer(ctx context.Context, id int64, orgID int64) (*folder.Folder, error) {
ctx, span := s.tracer.Start(ctx, "folder.getFolderByIDFromApiServer")
defer span.End()
if id == 0 {
return &folder.GeneralFolder, nil
}
folderkey := &resourcepb.ResourceKey{
Namespace: s.k8sclient.GetNamespace(orgID),
Group: folderv1.FolderResourceInfo.GroupVersionResource().Group,
Resource: folderv1.FolderResourceInfo.GroupVersionResource().Resource,
}
request := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: folderkey,
Fields: []*resourcepb.Requirement{},
Labels: []*resourcepb.Requirement{
{
Key: utils.LabelKeyDeprecatedInternalID, // nolint:staticcheck
Operator: string(selection.In),
Values: []string{fmt.Sprintf("%d", id)},
},
},
},
Limit: folderSearchLimit}
res, err := s.k8sclient.Search(ctx, orgID, request)
if err != nil {
return nil, err
}
hits, err := dashboardsearch.ParseResults(res, 0)
if err != nil {
return nil, err
}
return s.returnFirstFolderSearchResult(ctx, orgID, hits)
}
func (s *Service) returnFirstFolderSearchResult(ctx context.Context, orgID int64, hits v0alpha1.SearchResults) (*folder.Folder, error) {
if len(hits.Hits) == 0 {
return nil, dashboards.ErrFolderNotFound
}
uid := hits.Hits[0].Name
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
f, err := s.Get(ctx, &folder.GetFolderQuery{UID: &uid, SignedInUser: user, OrgID: orgID})
if err != nil {
return nil, err
}
return f, nil
}
func (s *Service) getFolderByTitleFromApiServer(ctx context.Context, orgID int64, title string, parentUID *string) (*folder.Folder, error) {
ctx, span := s.tracer.Start(ctx, "folder.getFolderByTitleFromApiServer")
defer span.End()
if title == "" {
return nil, dashboards.ErrFolderTitleEmpty
}
folderkey := &resourcepb.ResourceKey{
Namespace: s.k8sclient.GetNamespace(orgID),
Group: folderv1.FolderResourceInfo.GroupVersionResource().Group,
Resource: folderv1.FolderResourceInfo.GroupVersionResource().Resource,
}
request := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: folderkey,
Fields: []*resourcepb.Requirement{
{
Key: resource.SEARCH_FIELD_TITLE_PHRASE, // nolint:staticcheck
Operator: string(selection.Equals),
Values: []string{title},
},
},
Labels: []*resourcepb.Requirement{},
},
Limit: folderSearchLimit}
if parentUID != nil {
req := []*resourcepb.Requirement{{
Key: resource.SEARCH_FIELD_FOLDER,
Operator: string(selection.In),
Values: []string{*parentUID},
}}
request.Options.Fields = append(request.Options.Fields, req...)
}
res, err := s.k8sclient.Search(ctx, orgID, request)
if err != nil {
return nil, err
}
hits, err := dashboardsearch.ParseResults(res, 0)
if err != nil {
return nil, err
}
// If we're searching for top-level folders (parentUID == nil), and the first result is not in the root folder, remove it from the results.
for parentUID == nil && len(hits.Hits) > 0 && hits.Hits[0].Folder != "" {
hits.Hits = hits.Hits[1:]
}
return s.returnFirstFolderSearchResult(ctx, orgID, hits)
}
func (s *Service) getChildrenFromApiServer(ctx context.Context, q *folder.GetChildrenQuery) ([]*folder.FolderReference, error) {
ctx, span := s.tracer.Start(ctx, "folder.getChildrenFromApiServer")
defer span.End()
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)
}
// we only need to check access to the folder
// if the parent is accessible then the subfolders are accessible as well (due to inheritance)
evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(q.UID))
if q.Permission == dashboardaccess.PERMISSION_EDIT {
evaluator = accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(q.UID))
}
if hasAccess, err := s.accessControl.Evaluate(ctx, q.SignedInUser, evaluator); err != nil || !hasAccess {
if err != nil {
return nil, err
}
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.FolderReference, error) {
ctx, span := s.tracer.Start(ctx, "folder.getRootFoldersFromApiServer")
defer span.End()
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.FolderReference{folder.SharedWithMeFolder.ToFolderReference()}, children...)
}
return children, nil
}
func (s *Service) getParentsFromApiServer(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) {
ctx, span := s.tracer.Start(ctx, "folder.getParentsFromApiServer")
defer span.End()
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) {
ctx, span := s.tracer.Start(ctx, "folder.createOnApiServer")
defer span.End()
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
}
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,
// pass along provisioning details
ManagerKindClassicFP: cmd.ManagerKindClassicFP, // nolint:staticcheck
}
f, err := s.unifiedStore.Create(ctx, *cmd)
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.updateOnApiServer")
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
}
evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.UID))
if hasAccess, err := s.accessControl.Evaluate(ctx, cmd.SignedInUser, evaluator); err != nil || !hasAccess {
if err != nil {
return nil, err
}
return nil, toFolderError(dashboards.ErrDashboardUpdateAccessDenied)
}
user := cmd.SignedInUser
folder, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{
UID: cmd.UID,
OrgID: cmd.OrgID,
NewTitle: cmd.NewTitle,
NewDescription: cmd.NewDescription,
SignedInUser: user,
Overwrite: cmd.Overwrite,
Version: cmd.Version,
ManagerKindClassicFP: cmd.ManagerKindClassicFP, // nolint:staticcheck
})
if err != nil {
return nil, err
}
if cmd.NewTitle != nil {
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
if err := s.publishFolderFullPathUpdatedEventViaApiServer(ctx, folder.Updated, cmd.OrgID, cmd.UID); err != nil {
return nil, err
}
}
// always expose the dashboard store sequential ID
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
return folder, nil
}
func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFolderCommand) error {
ctx, span := s.tracer.Start(ctx, "folder.deleteFromApiServer")
defer span.End()
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")
}
evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.UID))
if hasAccess, err := s.accessControl.Evaluate(ctx, cmd.SignedInUser, evaluator); err != nil || !hasAccess {
if err != nil {
return toFolderError(err)
}
return dashboards.ErrFolderAccessDenied
}
descFolders, err := s.unifiedStore.GetDescendants(ctx, cmd.OrgID, cmd.UID)
if err != nil {
return err
}
descFolders = folder.SortByPostorder(descFolders)
folders := []string{}
for _, f := range descFolders {
folders = append(folders, f.UID)
}
// must delete children first, then the parent folder
s.log.InfoContext(ctx, "deleting folder with descendants", "org_id", cmd.OrgID, "uid", cmd.UID, "folderUIDs", strings.Join(folders, ","))
folders = append(folders, cmd.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)
}
libraryPanelSrv, ok := s.registry[entity.StandardKindLibraryPanel]
if !ok {
return folder.ErrInternal.Errorf("no library panel service found in registry")
}
// /* TODO: after a decision regarding folder deletion permissions has been made
// (https://github.com/grafana/grafana-enterprise/issues/5144),
// remove the following call to DeleteInFolders
// and remove "user" from the signature of DeleteInFolder in the folder RegistryService.
// Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903
// */
// Obs: DeleteInFolders only deletes dangling library panels (not linked to any dashboard) and throws errors if there are connections
if err := libraryPanelSrv.DeleteInFolders(ctx, cmd.OrgID, folders, cmd.SignedInUser); err != nil {
s.log.Error("failed to delete dangling library panels in folders", "error", err, "folders", strings.Join(folders, ","))
return err
}
// We need a list of dashboard uids inside the folder to delete related dashboards & public dashboards -
// we cannot use the dashboard service directly due to circular dependencies, so use the search client to get the dashboards
request := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Labels: []*resourcepb.Requirement{},
Fields: []*resourcepb.Requirement{
{
Key: resource.SEARCH_FIELD_FOLDER,
Operator: string(selection.In),
Values: folders,
},
},
},
Limit: folderSearchLimit}
hits, err := dashboardsearch.SearchAll(ctx, cmd.OrgID, request, s.dashboardK8sClient.Search)
if err != nil {
return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err)
}
dashboardUIDs := make([]string, len(hits.Hits))
for i, dashboard := range hits.Hits {
dashboardUIDs[i] = dashboard.Name
err = s.dashboardK8sClient.Delete(ctx, dashboard.Name, cmd.OrgID, metav1.DeleteOptions{})
if err != nil {
return folder.ErrInternal.Errorf("failed to delete child dashboard: %w", err)
}
}
// Delete all public dashboards in the folders
err = s.publicDashboardService.DeleteByDashboardUIDs(ctx, cmd.OrgID, dashboardUIDs)
if err != nil {
return folder.ErrInternal.Errorf("failed to delete public dashboards: %w", err)
}
}
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.moveOnApiServer")
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")
}
// 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
}
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) {
ctx, span := s.tracer.Start(ctx, "folder.canMoveViaApiServer")
defer span.End()
// 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 {
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) {
ctx, span := s.tracer.Start(ctx, "folder.getFolderAndParentUIDScopesViaApiServer")
defer span.End()
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) {
ctx, span := s.tracer.Start(ctx, "folder.getDescendantCountsFromApiServer")
defer span.End()
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")
}
return s.unifiedStore.(*FolderUnifiedStoreImpl).CountFolderContent(ctx, q.OrgID, *q.UID)
}