2025-01-14 05:15:35 +08:00
package folderimpl
import (
"context"
2025-01-22 12:45:59 +08:00
"fmt"
2025-01-30 07:44:42 +08:00
"strconv"
2025-01-14 05:15:35 +08:00
"strings"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/exp/slices"
2025-01-22 12:45:59 +08:00
"k8s.io/apimachinery/pkg/selection"
2025-01-14 05:15:35 +08:00
"github.com/grafana/grafana/pkg/apimachinery/identity"
2025-01-22 12:45:59 +08:00
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
2025-01-14 05:15:35 +08:00
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/metrics"
2025-01-30 07:44:42 +08:00
"github.com/grafana/grafana/pkg/infra/slugify"
2025-01-14 05:15:35 +08:00
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
2025-01-22 12:45:59 +08:00
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
2025-01-14 05:15:35 +08:00
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
2025-01-30 07:44:42 +08:00
"github.com/grafana/grafana/pkg/services/search/model"
2025-01-14 05:15:35 +08:00
"github.com/grafana/grafana/pkg/services/store/entity"
2025-01-22 12:45:59 +08:00
"github.com/grafana/grafana/pkg/storage/unified/resource"
2025-01-30 07:44:42 +08:00
"github.com/grafana/grafana/pkg/storage/unified/search"
2025-01-14 05:15:35 +08:00
"github.com/grafana/grafana/pkg/util"
2025-02-04 12:38:57 +08:00
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2025-01-14 05:15:35 +08:00
)
2025-02-20 02:06:26 +08:00
const folderSearchLimit = 100000
2025-01-14 05:15:35 +08:00
func ( s * Service ) getFoldersFromApiServer ( ctx context . Context , q folder . GetFoldersQuery ) ( [ ] * folder . Folder , error ) {
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 ) {
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
}
2025-01-22 12:45:59 +08:00
ctx = identity . WithRequester ( ctx , q . SignedInUser )
2025-01-14 05:15:35 +08:00
var dashFolder * folder . Folder
var err error
switch {
2025-01-29 03:00:57 +08:00
case q . UID != nil && * q . UID != "" :
2025-01-14 05:15:35 +08:00
dashFolder , err = s . unifiedStore . Get ( ctx , * q )
if err != nil {
2025-01-22 12:45:59 +08:00
return nil , toFolderError ( err )
2025-01-14 05:15:35 +08:00
}
// nolint:staticcheck
2025-01-29 03:00:57 +08:00
case q . ID != nil && * q . ID != 0 :
2025-01-22 12:45:59 +08:00
dashFolder , err = s . getFolderByIDFromApiServer ( ctx , * q . ID , q . OrgID )
if err != nil {
return nil , toFolderError ( err )
}
2025-01-29 03:00:57 +08:00
case q . Title != nil && * q . Title != "" :
2025-01-23 03:47:46 +08:00
dashFolder , err = s . getFolderByTitleFromApiServer ( ctx , q . OrgID , * q . Title , q . ParentUID )
if err != nil {
return nil , toFolderError ( err )
}
2025-01-14 05:15:35 +08:00
default :
2025-01-29 03:00:57 +08:00
return & folder . GeneralFolder , nil
2025-01-14 05:15:35 +08:00
}
if dashFolder . IsGeneral ( ) {
return dashFolder , nil
}
// do not get guardian by the folder ID because it differs from the nested folder ID
// and the legacy folder ID has been associated with the permissions:
// use the folde UID instead that is the same for both
g , err := guardian . NewByFolder ( ctx , dashFolder , dashFolder . OrgID , q . SignedInUser )
if err != nil {
return nil , err
}
if canView , err := g . CanView ( ) ; 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
f , err = s . setFullpath ( ctx , f , q . SignedInUser , false )
if err != nil {
return nil , err
}
return f , err
}
2025-01-30 07:44:42 +08:00
// 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 ) {
if query . OrgID == 0 {
requester , err := identity . GetRequester ( ctx )
if err != nil {
return nil , err
}
query . OrgID = requester . GetOrgID ( )
}
request := & resource . ResourceSearchRequest {
Options : & resource . ListOptions {
Key : & resource . ResourceKey {
2025-02-12 03:14:25 +08:00
Namespace : s . k8sclient . GetNamespace ( query . OrgID ) ,
2025-01-30 07:44:42 +08:00
Group : v0alpha1 . FolderResourceInfo . GroupVersionResource ( ) . Group ,
Resource : v0alpha1 . FolderResourceInfo . GroupVersionResource ( ) . Resource ,
} ,
Fields : [ ] * resource . Requirement { } ,
Labels : [ ] * resource . Requirement { } ,
} ,
2025-02-20 02:06:26 +08:00
Limit : folderSearchLimit }
2025-01-30 07:44:42 +08:00
if len ( query . UIDs ) > 0 {
request . Options . Fields = [ ] * resource . 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 , & resource . Requirement {
Key : utils . LabelKeyDeprecatedInternalID , // nolint:staticcheck
Operator : string ( selection . In ) ,
Values : values ,
} )
}
if query . Title != "" {
// allow wildcard search
request . Query = "*" + strings . ToLower ( query . Title ) + "*"
2025-01-30 09:42:06 +08:00
// if using query, you need to specify the fields you want
request . Fields = dashboardsearch . IncludeFields
2025-01-30 07:44:42 +08:00
}
if query . Limit > 0 {
request . Limit = query . Limit
}
2025-02-12 03:14:25 +08:00
res , err := s . k8sclient . Search ( ctx , query . OrgID , request )
2025-01-30 07:44:42 +08:00
if err != nil {
return nil , err
}
parsedResults , err := dashboardsearch . ParseResults ( res , 0 )
if err != nil {
return nil , err
}
hitList := make ( [ ] * model . Hit , len ( parsedResults . Hits ) )
foldersMap := map [ string ] * folder . Folder { }
for i , item := range parsedResults . Hits {
f , ok := foldersMap [ item . Folder ]
if ! ok {
f , err = s . Get ( ctx , & folder . GetFolderQuery {
UID : & item . Folder ,
OrgID : query . OrgID ,
SignedInUser : query . SignedInUser ,
} )
if err != nil {
return nil , err
}
foldersMap [ item . Folder ] = f
}
slug := slugify . Slugify ( item . Title )
hitList [ i ] = & model . Hit {
ID : item . Field . GetNestedInt64 ( search . DASHBOARD_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 ,
FolderTitle : f . Title ,
FolderID : f . ID , // nolint:staticcheck
}
}
return hitList , nil
}
2025-01-22 12:45:59 +08:00
func ( s * Service ) getFolderByIDFromApiServer ( ctx context . Context , id int64 , orgID int64 ) ( * folder . Folder , error ) {
if id == 0 {
return & folder . GeneralFolder , nil
}
folderkey := & resource . ResourceKey {
2025-02-12 03:14:25 +08:00
Namespace : s . k8sclient . GetNamespace ( orgID ) ,
2025-01-22 12:45:59 +08:00
Group : v0alpha1 . FolderResourceInfo . GroupVersionResource ( ) . Group ,
Resource : v0alpha1 . FolderResourceInfo . GroupVersionResource ( ) . Resource ,
}
request := & resource . ResourceSearchRequest {
Options : & resource . ListOptions {
Key : folderkey ,
Fields : [ ] * resource . Requirement { } ,
Labels : [ ] * resource . Requirement {
{
Key : utils . LabelKeyDeprecatedInternalID , // nolint:staticcheck
Operator : string ( selection . In ) ,
Values : [ ] string { fmt . Sprintf ( "%d" , id ) } ,
} ,
} ,
} ,
2025-02-20 02:06:26 +08:00
Limit : folderSearchLimit }
2025-01-22 12:45:59 +08:00
2025-02-12 03:14:25 +08:00
res , err := s . k8sclient . Search ( ctx , orgID , request )
2025-01-22 12:45:59 +08:00
if err != nil {
return nil , err
}
hits , err := dashboardsearch . ParseResults ( res , 0 )
2025-01-23 03:47:46 +08:00
if err != nil {
return nil , err
}
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 ) {
if title == "" {
return nil , dashboards . ErrFolderTitleEmpty
}
folderkey := & resource . ResourceKey {
2025-02-12 03:14:25 +08:00
Namespace : s . k8sclient . GetNamespace ( orgID ) ,
2025-01-23 03:47:46 +08:00
Group : v0alpha1 . FolderResourceInfo . GroupVersionResource ( ) . Group ,
Resource : v0alpha1 . FolderResourceInfo . GroupVersionResource ( ) . Resource ,
}
request := & resource . ResourceSearchRequest {
Options : & resource . ListOptions {
2025-02-26 20:57:12 +08:00
Key : folderkey ,
Fields : [ ] * resource . Requirement { } ,
2025-01-23 03:47:46 +08:00
Labels : [ ] * resource . Requirement { } ,
} ,
2025-02-26 20:57:12 +08:00
Query : title ,
2025-02-20 02:06:26 +08:00
Limit : folderSearchLimit }
2025-01-23 03:47:46 +08:00
if parentUID != nil {
req := [ ] * resource . Requirement { {
Key : resource . SEARCH_FIELD_FOLDER ,
Operator : string ( selection . In ) ,
Values : [ ] string { * parentUID } ,
} }
request . Options . Fields = append ( request . Options . Fields , req ... )
}
2025-02-12 03:14:25 +08:00
res , err := s . k8sclient . Search ( ctx , orgID , request )
2025-01-23 03:47:46 +08:00
if err != nil {
return nil , err
}
hits , err := dashboardsearch . ParseResults ( res , 0 )
2025-01-22 12:45:59 +08:00
if err != nil {
return nil , err
}
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
}
2025-01-14 05:15:35 +08:00
func ( s * Service ) getChildrenFromApiServer ( ctx context . Context , q * folder . GetChildrenQuery ) ( [ ] * folder . Folder , error ) {
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 )
}
var err error
// TODO: figure out what to do with Guardian
// we only need to check access to the folder
// if the parent is accessible then the subfolders are accessible as well (due to inheritance)
f := & folder . Folder {
UID : q . UID ,
}
g , err := guardian . NewByFolder ( ctx , f , q . OrgID , q . SignedInUser )
if err != nil {
return nil , err
}
guardianFunc := g . CanView
if q . Permission == dashboardaccess . PERMISSION_EDIT {
guardianFunc = g . CanEdit
}
hasAccess , err := guardianFunc ( )
if err != nil {
return nil , err
}
if ! hasAccess {
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 . Folder , error ) {
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 . Folder { & folder . SharedWithMeFolder } , children ... )
}
return children , nil
}
func ( s * Service ) getParentsFromApiServer ( ctx context . Context , q folder . GetParentsQuery ) ( [ ] * folder . Folder , error ) {
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 ) {
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
}
user := cmd . SignedInUser
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 ,
}
f , err := s . unifiedStore . Create ( ctx , * cmd )
if err != nil {
return nil , err
}
f , err = s . setFullpath ( ctx , f , user , false )
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.Update" )
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
}
f := & folder . Folder {
UID : cmd . UID ,
}
g , err := guardian . NewByFolder ( ctx , f , cmd . OrgID , cmd . SignedInUser )
if err != nil {
return nil , err
}
if canSave , err := g . CanSave ( ) ; err != nil || ! canSave {
if err != nil {
return nil , err
}
return nil , toFolderError ( dashboards . ErrDashboardUpdateAccessDenied )
}
user := cmd . SignedInUser
foldr , err := s . unifiedStore . Update ( ctx , folder . UpdateFolderCommand {
UID : cmd . UID ,
OrgID : cmd . OrgID ,
NewTitle : cmd . NewTitle ,
NewDescription : cmd . NewDescription ,
SignedInUser : user ,
} )
if err != nil {
return nil , err
}
if cmd . NewTitle != nil {
metrics . MFolderIDsServiceCount . WithLabelValues ( metrics . Folder ) . Inc ( )
if err := s . publishFolderFullPathUpdatedEventViaApiServer ( ctx , foldr . Updated , cmd . OrgID , cmd . UID ) ; err != nil {
return nil , err
}
}
// always expose the dashboard store sequential ID
metrics . MFolderIDsServiceCount . WithLabelValues ( metrics . Folder ) . Inc ( )
return foldr , nil
}
func ( s * Service ) deleteFromApiServer ( ctx context . Context , cmd * folder . DeleteFolderCommand ) error {
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" )
}
f := & folder . Folder {
UID : cmd . UID ,
}
guard , err := guardian . NewByFolder ( ctx , f , cmd . OrgID , cmd . SignedInUser )
if err != nil {
return err
}
if canSave , err := guard . CanDelete ( ) ; err != nil || ! canSave {
if err != nil {
return toFolderError ( err )
}
return dashboards . ErrFolderAccessDenied
}
descFolders , err := s . unifiedStore . GetDescendants ( ctx , cmd . OrgID , cmd . UID )
if err != nil {
return err
}
2025-02-20 08:16:05 +08:00
folders := [ ] string { }
2025-01-14 05:15:35 +08:00
for _ , f := range descFolders {
folders = append ( folders , f . UID )
}
2025-02-20 08:16:05 +08:00
// must delete children first, then the parent folder
folders = append ( folders , cmd . UID )
2025-01-14 05:15:35 +08:00
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 )
}
2025-01-24 04:23:59 +08:00
2025-02-19 07:11:26 +08:00
// 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 := & resource . ResourceSearchRequest {
Options : & resource . ListOptions {
Labels : [ ] * resource . Requirement { } ,
Fields : [ ] * resource . Requirement {
{
Key : resource . SEARCH_FIELD_FOLDER ,
Operator : string ( selection . In ) ,
Values : folders ,
2025-01-29 06:06:53 +08:00
} ,
2025-02-19 07:11:26 +08:00
} ,
} ,
2025-02-20 02:06:26 +08:00
Limit : folderSearchLimit }
2025-02-19 07:11:26 +08:00
res , err := s . dashboardK8sClient . Search ( ctx , cmd . OrgID , request )
if err != nil {
return folder . ErrInternal . Errorf ( "failed to fetch dashboards: %w" , err )
}
hits , err := dashboardsearch . ParseResults ( res , 0 )
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 { } )
2025-01-24 04:23:59 +08:00
if err != nil {
2025-02-19 07:11:26 +08:00
return folder . ErrInternal . Errorf ( "failed to delete child dashboard: %w" , err )
2025-01-24 04:23:59 +08:00
}
}
2025-02-19 07:11:26 +08:00
// 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 )
}
2025-01-14 05:15:35 +08:00
}
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.Move" )
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" )
}
f , err := s . unifiedStore . Get ( ctx , folder . GetFolderQuery {
UID : & cmd . UID ,
OrgID : cmd . OrgID ,
SignedInUser : cmd . SignedInUser ,
} )
if err != nil {
return nil , err
}
if f != nil && f . ParentUID == 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
}
// here we get the folder, we need to get the height of current folder
// and the depth of the new parent folder, the sum can't bypass 8
folderHeight , err := s . unifiedStore . GetHeight ( ctx , cmd . UID , cmd . OrgID , & cmd . NewParentUID )
if err != nil {
return nil , err
}
parents , err := s . unifiedStore . GetParents ( ctx , folder . GetParentsQuery { UID : cmd . NewParentUID , OrgID : cmd . OrgID } )
if err != nil {
return nil , err
}
// height of the folder that is being moved + this current folder itself + depth of the NewParent folder should be less than or equal MaxNestedFolderDepth
if folderHeight + len ( parents ) + 1 > folder . MaxNestedFolderDepth {
return nil , folder . ErrMaximumDepthReached . Errorf ( "failed to move folder" )
}
for _ , parent := range parents {
// if the current folder is already a parent of newparent, we should return error
if parent . UID == cmd . UID {
return nil , folder . ErrCircularReference . Errorf ( "failed to move folder" )
}
}
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 ) {
// 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 ) {
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 ) {
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" )
}
if s . features . IsEnabledGlobally ( featuremgmt . FlagK8SFolderCounts ) {
return s . unifiedStore . ( * FolderUnifiedStoreImpl ) . CountFolderContent ( ctx , q . OrgID , * q . UID )
}
folders := [ ] string { * q . UID }
countsMap := make ( folder . DescendantCounts , len ( s . registry ) + 1 )
descendantFolders , err := s . unifiedStore . GetDescendants ( ctx , q . OrgID , * q . UID )
if err != nil {
s . log . ErrorContext ( ctx , "failed to get descendant folders" , "error" , err )
return nil , err
}
for _ , f := range descendantFolders {
folders = append ( folders , f . UID )
}
countsMap [ entity . StandardKindFolder ] = int64 ( len ( descendantFolders ) )
for _ , v := range s . registry {
c , err := v . CountInFolders ( ctx , q . OrgID , folders , q . SignedInUser )
if err != nil {
s . log . ErrorContext ( ctx , "failed to count folder descendants" , "error" , err )
return nil , err
}
countsMap [ v . Kind ( ) ] = c
}
return countsMap , nil
}