Library Panels: add a folder tree cache for getAllHandler (#117475)

* fix(library-panels): cache folder tree for get all

* chore: re-add commented test

* Make cache global and fallback to service if folder is not found

* fix linter

* remove leftover comment and add adjust accessible checks
This commit is contained in:
Rafael Bortolon Paulovic 2026-02-10 11:00:18 +01:00 committed by GitHub
parent e6eb555c42
commit b3fd56dc4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1054 additions and 42 deletions

183
pkg/services/folder/tree.go Normal file
View file

@ -0,0 +1,183 @@
package folder
import (
"iter"
)
// FolderTree represents a tree structure of folders for efficient ancestor/descendant traversal.
type FolderTree struct {
// Nodes contains all folder nodes in the tree.
Nodes []FolderNode
// Index maps folder UID to its position in Nodes for O(1) lookup.
Index map[string]int
// IDIndex maps folder ID to its position in Nodes for O(1) lookup.
IDIndex map[int64]int
}
// FolderNode represents a single folder in the tree.
type FolderNode struct {
// Deprecated: use UID instead
ID int64
UID string
Title string
Parent int
Children []int
Accessible bool // Accessible is true if the node was returned by GetFolders
}
// NewFolderTree builds a folder tree from a list of folders.
// The General/Root folder is always inserted as the root node at index 0.
// Folders with no ParentUID become children of the General folder.
func NewFolderTree(folders []*Folder) *FolderTree {
t := &FolderTree{
Index: make(map[string]int, len(folders)+1),
IDIndex: make(map[int64]int, len(folders)+1),
Nodes: make([]FolderNode, 0, len(folders)+1),
}
// Insert the General folder as the root (always at index 0).
// This is the only node with Parent = -1.
t.Nodes = append(t.Nodes, FolderNode{
UID: GeneralFolderUID,
Title: "General",
Parent: -1,
Accessible: true,
})
t.Index[GeneralFolderUID] = 0
t.Index[RootFolderUID] = 0 // Point RootFolderUID to root node
t.IDIndex[0] = 0 // ID 0 for General folder
for _, f := range folders {
var parentUID *string
if f.ParentUID != "" {
parentUID = &f.ParentUID
}
// Folders from GetFolders are accessible (not placeholders)
t.insert(f.ID, f.UID, f.Title, parentUID, true)
}
// Build Children relationships from Parent fields
for i := 1; i < len(t.Nodes); i++ { // Skip General folder (index 0)
parent := t.Nodes[i].Parent
if parent != -1 {
t.Nodes[parent].Children = append(t.Nodes[parent].Children, i)
}
}
return t
}
// insert adds a folder to the tree.
// Folders with no parent become children of the General folder (index 0).
func (t *FolderTree) insert(id int64, uid string, title string, parentUID *string, accessible bool) int {
// Default parent is the General folder (index 0).
// Only the General folder itself has Parent = -1.
parent := 0
if parentUID != nil {
// Find or create parent
i, ok := t.Index[*parentUID]
if !ok {
// Insert parent as placeholder if it doesn't exist yet (ID=0 for placeholder, accessible=false)
i = t.insert(0, *parentUID, "", nil, false)
}
parent = i
}
i, ok := t.Index[uid]
if !ok {
// This node doesn't exist yet, add it to the index and append the new node
i = len(t.Nodes)
t.Index[uid] = i
if id != 0 {
t.IDIndex[id] = i
}
t.Nodes = append(t.Nodes, FolderNode{
ID: id,
UID: uid,
Title: title,
Parent: parent,
Accessible: accessible,
})
} else {
// Node exists (was a placeholder). Update with actual folder data.
t.Nodes[i].Parent = parent
if title != "" {
t.Nodes[i].Title = title
}
if id != 0 && t.Nodes[i].ID == 0 {
t.Nodes[i].ID = id
t.IDIndex[id] = i
}
t.Nodes[i].Accessible = accessible
}
return i
}
// Ancestors returns an iterator that yields all accessible ancestor folders of the given UID,
// starting from the immediate parent and going up to the root.
func (t *FolderTree) Ancestors(uid string) iter.Seq[FolderNode] {
current, ok := t.Index[uid]
if !ok {
return func(yield func(FolderNode) bool) {}
}
current = t.Nodes[current].Parent
return func(yield func(FolderNode) bool) {
for {
if current == -1 || !t.Nodes[current].Accessible || !yield(t.Nodes[current]) {
return
}
current = t.Nodes[current].Parent
}
}
}
// Children returns an iterator that yields all descendant folders of the given UID
// using breadth-first traversal.
func (t *FolderTree) Children(uid string) iter.Seq[FolderNode] {
current, ok := t.Index[uid]
if !ok {
return func(yield func(FolderNode) bool) {}
}
queue := t.Nodes[current].Children
return func(yield func(FolderNode) bool) {
for len(queue) > 0 {
current, queue = queue[0], queue[1:]
if !yield(t.Nodes[current]) {
return
}
queue = append(queue, t.Nodes[current].Children...)
}
}
}
// Contains returns true if the folder with the given UID exists in the tree.
func (t *FolderTree) Contains(uid string) bool {
if i, ok := t.Index[uid]; ok {
return t.Nodes[i].Accessible
}
return false
}
// GetTitle returns the title of the folder with the given UID.
// Returns empty string if the folder is not found.
func (t *FolderTree) GetTitle(uid string) string {
if i, ok := t.Index[uid]; ok {
node := t.Nodes[i]
if node.Accessible {
return node.Title
}
}
return ""
}
// GetByID returns the folder node with the given ID.
func (t *FolderTree) GetByID(id int64) (FolderNode, bool) {
if i, ok := t.IDIndex[id]; ok {
return t.Nodes[i], true
}
return FolderNode{}, false
}

View file

@ -0,0 +1,388 @@
package folder
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewFolderTree(t *testing.T) {
t.Run("empty folder list still has general folder", func(t *testing.T) {
tree := NewFolderTree([]*Folder{})
require.NotNil(t, tree)
// General folder is always present at index 0
assert.Len(t, tree.Nodes, 1)
assert.Equal(t, GeneralFolderUID, tree.Nodes[0].UID)
assert.Equal(t, -1, tree.Nodes[0].Parent) // General folder has no parent
// Both "" and "general" map to index 0
assert.True(t, tree.Contains(""))
assert.True(t, tree.Contains(GeneralFolderUID))
})
t.Run("single folder becomes child of general", func(t *testing.T) {
folders := []*Folder{
{UID: "a", Title: "Folder A"},
}
tree := NewFolderTree(folders)
require.NotNil(t, tree)
// General folder + 1 user folder
assert.Len(t, tree.Nodes, 2)
assert.Contains(t, tree.Index, "a")
assert.Equal(t, "Folder A", tree.GetTitle("a"))
// "a" should be a child of the general folder
assert.Equal(t, 0, tree.Nodes[tree.Index["a"]].Parent)
})
t.Run("parent-child relationship", func(t *testing.T) {
folders := []*Folder{
{UID: "parent", Title: "Parent"},
{UID: "child", Title: "Child", ParentUID: "parent"},
}
tree := NewFolderTree(folders)
// General folder + 2 user folders
require.Len(t, tree.Nodes, 3)
parentIdx := tree.Index["parent"]
childIdx := tree.Index["child"]
// Parent should have child in its children
assert.Contains(t, tree.Nodes[parentIdx].Children, childIdx)
// Child should have parent as its parent
assert.Equal(t, parentIdx, tree.Nodes[childIdx].Parent)
// Parent should be a child of general folder
assert.Equal(t, 0, tree.Nodes[parentIdx].Parent)
})
t.Run("deep hierarchy", func(t *testing.T) {
folders := []*Folder{
{UID: "root", Title: "Root"},
{UID: "level1", Title: "Level 1", ParentUID: "root"},
{UID: "level2", Title: "Level 2", ParentUID: "level1"},
{UID: "level3", Title: "Level 3", ParentUID: "level2"},
}
tree := NewFolderTree(folders)
// General folder + 4 user folders
require.Len(t, tree.Nodes, 5)
// Verify the hierarchy - "root" is now a child of general folder
assert.Equal(t, 0, tree.Nodes[tree.Index["root"]].Parent) // root's parent is general
assert.Equal(t, tree.Index["root"], tree.Nodes[tree.Index["level1"]].Parent)
assert.Equal(t, tree.Index["level1"], tree.Nodes[tree.Index["level2"]].Parent)
assert.Equal(t, tree.Index["level2"], tree.Nodes[tree.Index["level3"]].Parent)
})
}
func TestFolderTree_Ancestors(t *testing.T) {
folders := []*Folder{
{UID: "root", Title: "Root"},
{UID: "level1", Title: "Level 1", ParentUID: "root"},
{UID: "level2", Title: "Level 2", ParentUID: "level1"},
{UID: "level3", Title: "Level 3", ParentUID: "level2"},
}
tree := NewFolderTree(folders)
t.Run("general folder has no ancestors", func(t *testing.T) {
var ancestors []string
for node := range tree.Ancestors(GeneralFolderUID) {
ancestors = append(ancestors, node.UID)
}
assert.Len(t, ancestors, 0)
})
t.Run("top-level folder has general as ancestor", func(t *testing.T) {
var ancestors []string
for node := range tree.Ancestors("root") {
ancestors = append(ancestors, node.UID)
}
assert.Equal(t, []string{GeneralFolderUID}, ancestors)
})
t.Run("leaf folder has all ancestors including general", func(t *testing.T) {
var ancestors []string
for node := range tree.Ancestors("level3") {
ancestors = append(ancestors, node.UID)
}
assert.Equal(t, []string{"level2", "level1", "root", GeneralFolderUID}, ancestors)
})
t.Run("middle folder has partial ancestors including general", func(t *testing.T) {
var ancestors []string
for node := range tree.Ancestors("level2") {
ancestors = append(ancestors, node.UID)
}
assert.Equal(t, []string{"level1", "root", GeneralFolderUID}, ancestors)
})
t.Run("non-existent folder returns empty iterator", func(t *testing.T) {
var ancestors []string
for node := range tree.Ancestors("nonexistent") {
ancestors = append(ancestors, node.UID)
}
assert.Len(t, ancestors, 0)
})
t.Run("stops at first inaccessible ancestor in chain", func(t *testing.T) {
// Create: child -> accessible-parent -> placeholder-grandparent
foldersPartial := []*Folder{
{UID: "accessible-parent", Title: "Accessible Parent", ParentUID: "placeholder-grandparent"},
{UID: "child", Title: "Child", ParentUID: "accessible-parent"},
}
treePartial := NewFolderTree(foldersPartial)
var ancestors []string
for node := range treePartial.Ancestors("child") {
ancestors = append(ancestors, node.UID)
}
// Should only have accessible-parent, stops at placeholder-grandparent
assert.Equal(t, []string{"accessible-parent"}, ancestors)
})
}
func TestFolderTree_Children(t *testing.T) {
folders := []*Folder{
{UID: "root", Title: "Root"},
{UID: "child1", Title: "Child 1", ParentUID: "root"},
{UID: "child2", Title: "Child 2", ParentUID: "root"},
{UID: "grandchild1", Title: "Grandchild 1", ParentUID: "child1"},
{UID: "grandchild2", Title: "Grandchild 2", ParentUID: "child1"},
}
tree := NewFolderTree(folders)
t.Run("general folder has all folders as descendants", func(t *testing.T) {
childUIDs := make(map[string]bool)
for node := range tree.Children(GeneralFolderUID) {
childUIDs[node.UID] = true
}
assert.Len(t, childUIDs, 5)
assert.True(t, childUIDs["root"])
assert.True(t, childUIDs["child1"])
assert.True(t, childUIDs["child2"])
assert.True(t, childUIDs["grandchild1"])
assert.True(t, childUIDs["grandchild2"])
})
t.Run("top-level folder has all its descendants", func(t *testing.T) {
childUIDs := make(map[string]bool)
for node := range tree.Children("root") {
childUIDs[node.UID] = true
}
assert.Len(t, childUIDs, 4)
assert.True(t, childUIDs["child1"])
assert.True(t, childUIDs["child2"])
assert.True(t, childUIDs["grandchild1"])
assert.True(t, childUIDs["grandchild2"])
})
t.Run("leaf folder has no children", func(t *testing.T) {
var children []string
for node := range tree.Children("grandchild1") {
children = append(children, node.UID)
}
assert.Len(t, children, 0)
})
t.Run("middle folder has subtree children", func(t *testing.T) {
childUIDs := make(map[string]bool)
for node := range tree.Children("child1") {
childUIDs[node.UID] = true
}
assert.Len(t, childUIDs, 2)
assert.True(t, childUIDs["grandchild1"])
assert.True(t, childUIDs["grandchild2"])
})
t.Run("non-existent folder returns empty iterator", func(t *testing.T) {
var children []string
for node := range tree.Children("nonexistent") {
children = append(children, node.UID)
}
assert.Len(t, children, 0)
})
t.Run("children iterator should not yield duplicates", func(t *testing.T) {
folders := []*Folder{
{UID: "child", Title: "Child", ParentUID: "parent"},
{UID: "parent", Title: "Parent"},
}
tree := NewFolderTree(folders)
// Iterate children of General folder
var children []string
for node := range tree.Children(GeneralFolderUID) {
children = append(children, node.UID)
}
// Should see parent and child exactly once each
assert.Len(t, children, 2, "Should have exactly 2 descendants (parent and child)")
})
}
func TestFolderTree_Contains(t *testing.T) {
t.Run("general folder is always accessible", func(t *testing.T) {
tree := NewFolderTree([]*Folder{})
assert.True(t, tree.Contains(GeneralFolderUID))
assert.True(t, tree.Contains("")) // RootFolderUID
})
t.Run("folders from GetFolders are accessible", func(t *testing.T) {
folders := []*Folder{
{UID: "folder-a", Title: "Folder A"},
{UID: "folder-b", Title: "Folder B"},
}
tree := NewFolderTree(folders)
assert.True(t, tree.Contains("folder-a"))
assert.True(t, tree.Contains("folder-b"))
})
t.Run("placeholder ancestors are NOT accessible", func(t *testing.T) {
// Simulate GetFolders returning only child but not parent
// User has access to "child" but NOT "parent"
folders := []*Folder{
{UID: "child", Title: "Child Folder", ParentUID: "parent"},
}
tree := NewFolderTree(folders)
// Child is accessible (came from GetFolders)
assert.True(t, tree.Contains("child"))
// But parent is NOT accessible (placeholder)
assert.False(t, tree.Contains("parent"))
})
t.Run("deeply nested placeholder ancestors are NOT accessible", func(t *testing.T) {
// User only has access to the deepest folder
folders := []*Folder{
{UID: "level3", Title: "Level 3", ParentUID: "level2"},
}
tree := NewFolderTree(folders)
// level3 is accessible
assert.True(t, tree.Contains("level3"))
// level2 exists (placeholder) but not accessible
assert.False(t, tree.Contains("level2"))
})
t.Run("non-existent folder is not accessible", func(t *testing.T) {
tree := NewFolderTree([]*Folder{})
assert.False(t, tree.Contains("non-existent"))
})
t.Run("mixed accessible and placeholder folders", func(t *testing.T) {
// User has access to "parent" and "grandchild" but NOT "child"
// This is an edge case but let's verify the behavior
folders := []*Folder{
{UID: "parent", Title: "Parent"},
{UID: "grandchild", Title: "Grandchild", ParentUID: "child"},
}
tree := NewFolderTree(folders)
// parent is accessible (from GetFolders)
assert.True(t, tree.Contains("parent"))
// grandchild is accessible (from GetFolders)
assert.True(t, tree.Contains("grandchild"))
// child is a placeholder (grandchild's parent), NOT accessible
assert.False(t, tree.Contains("child"))
})
t.Run("accessible field is set correctly on nodes", func(t *testing.T) {
folders := []*Folder{
{UID: "accessible", Title: "Accessible", ParentUID: "placeholder"},
}
tree := NewFolderTree(folders)
// Verify Accessible field directly
accessibleIdx := tree.Index["accessible"]
placeholderIdx := tree.Index["placeholder"]
assert.True(t, tree.Nodes[accessibleIdx].Accessible)
assert.False(t, tree.Nodes[placeholderIdx].Accessible)
assert.True(t, tree.Nodes[0].Accessible) // General folder
})
}
func TestFolderTree_GetTitle(t *testing.T) {
folders := []*Folder{
{UID: "a", Title: "Folder A"},
{UID: "b", Title: "Folder B"},
}
tree := NewFolderTree(folders)
assert.Equal(t, "Folder A", tree.GetTitle("a"))
assert.Equal(t, "Folder B", tree.GetTitle("b"))
assert.Equal(t, "", tree.GetTitle("nonexistent"))
}
func TestFolderTree_OutOfOrderInsertion(t *testing.T) {
// Test that folders inserted out of order (child before parent) still work correctly
folders := []*Folder{
{UID: "child", Title: "Child", ParentUID: "parent"},
{UID: "parent", Title: "Parent"},
}
tree := NewFolderTree(folders)
// General folder + 2 user folders
require.Len(t, tree.Nodes, 3)
// Verify parent-child relationship is correct
parentIdx := tree.Index["parent"]
childIdx := tree.Index["child"]
assert.Contains(t, tree.Nodes[parentIdx].Children, childIdx)
assert.Equal(t, parentIdx, tree.Nodes[childIdx].Parent)
// Parent should be a child of general folder
assert.Equal(t, 0, tree.Nodes[parentIdx].Parent)
// Verify no duplicate children entries
assert.Len(t, tree.Nodes[parentIdx].Children, 1)
assert.Len(t, tree.Nodes[0].Children, 1)
// Verify titles are correct
assert.Equal(t, "Parent", tree.GetTitle("parent"))
assert.Equal(t, "Child", tree.GetTitle("child"))
}
func TestFolderTree_GetByID(t *testing.T) {
folders := []*Folder{
{ID: 1, UID: "a", Title: "Folder A"},
{ID: 2, UID: "b", Title: "Folder B"},
{ID: 3, UID: "c", Title: "Folder C", ParentUID: "a"},
}
tree := NewFolderTree(folders)
t.Run("get existing folder by ID", func(t *testing.T) {
node, ok := tree.GetByID(1)
require.True(t, ok)
assert.Equal(t, "a", node.UID)
assert.Equal(t, "Folder A", node.Title)
assert.Equal(t, int64(1), node.ID)
root, ok := tree.GetByID(0)
require.True(t, ok)
assert.Equal(t, GeneralFolderUID, root.UID)
})
t.Run("get non-existent folder by ID returns false", func(t *testing.T) {
_, ok := tree.GetByID(999)
assert.False(t, ok)
})
t.Run("IDIndex contains all folders with IDs and general folder", func(t *testing.T) {
// General folder has ID=0 which is not indexed
assert.Len(t, tree.IDIndex, 4)
assert.Contains(t, tree.IDIndex, int64(0))
assert.Contains(t, tree.IDIndex, int64(1))
assert.Contains(t, tree.IDIndex, int64(2))
assert.Contains(t, tree.IDIndex, int64(3))
})
}

View file

@ -52,18 +52,45 @@ func LibraryPanelUIDScopeResolver(l *LibraryElementService, folderSvc folder.Ser
return nil, err
}
// In case request cache ID is set, use cached tree
var tree *folder.FolderTree
if hasCache(ctx) {
tree, err = l.treeCache.get(ctx, user)
if err != nil {
return nil, err
}
}
libElDTO, err := l.getLibraryElementByUid(ctx, user, model.GetLibraryElementCommand{
UID: uid,
FolderName: dashboards.RootFolderName,
})
}, tree)
if err != nil {
return nil, err
}
inheritedScopes, err := dashboards.GetInheritedScopes(ctx, orgID, libElDTO.FolderUID, folderSvc)
if err != nil {
return nil, err
var inheritedScopes []string
if tree != nil {
inheritedScopes = getInheritedScopesFromTree(libElDTO.FolderUID, tree)
} else {
inheritedScopes, err = dashboards.GetInheritedScopes(ctx, orgID, libElDTO.FolderUID, folderSvc)
if err != nil {
return nil, err
}
}
return append(inheritedScopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(libElDTO.FolderUID), ScopeLibraryPanelsProvider.GetResourceScopeUID(uid)), nil
})
}
// getInheritedScopesFromTree returns ancestor scopes using a pre-built folder tree.
func getInheritedScopesFromTree(folderUID string, tree *folder.FolderTree) []string {
if folderUID == ac.GeneralFolderUID || folderUID == "" {
return nil
}
result := make([]string, 0)
for ancestor := range tree.Ancestors(folderUID) {
result = append(result, dashboards.ScopeFoldersProvider.GetResourceScopeUID(ancestor.UID))
}
return result
}

View file

@ -160,6 +160,7 @@ func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.
UID: web.Params(c.Req)[":uid"],
FolderName: dashboards.RootFolderName,
},
nil,
)
if err != nil {
return l.toLibraryElementError(err, "Failed to get library element")
@ -199,6 +200,8 @@ func (l *LibraryElementService) getAllHandler(c *contextmodel.ReqContext) respon
FolderFilter: c.Query("folderFilter"),
FolderFilterUIDs: c.Query("folderFilterUIDs"),
}
// Add cache entry to context for enabling folder tree caching
c.Req = c.Req.WithContext(withCache(c.Req.Context()))
elementsResult, err := l.getAllLibraryElements(c.Req.Context(), c.SignedInUser, query)
if err != nil {
return l.toLibraryElementError(err, "Failed to get library elements")
@ -293,7 +296,7 @@ func (l *LibraryElementService) getConnectionsHandler(c *contextmodel.ReqContext
// make sure the library element exists
element, err := l.getLibraryElementByUid(c.Req.Context(), c.SignedInUser, model.GetLibraryElementCommand{
UID: libraryPanelUID,
})
}, nil)
if err != nil {
return l.toLibraryElementError(err, "Failed to get library element")
}

View file

@ -0,0 +1,63 @@
package libraryelements
import (
"context"
"fmt"
"time"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/services/folder"
)
// cacheKey is the context key for knowing if it should use cache or not.
type cacheKey struct{}
// withCache returns a context with cache enabled.
func withCache(ctx context.Context) context.Context {
return context.WithValue(ctx, cacheKey{}, true)
}
// hasCache returns if the context has cache enabled.
func hasCache(ctx context.Context) bool {
_, ok := ctx.Value(cacheKey{}).(bool)
return ok
}
// folderTreeCache provides caching for folder trees.
type folderTreeCache struct {
cache *localcache.CacheService
folderSvc folder.Service
}
func newFolderTreeCache(folderSvc folder.Service) *folderTreeCache {
return &folderTreeCache{
cache: localcache.New(30*time.Second, 1*time.Minute),
folderSvc: folderSvc,
}
}
// get returns a folder tree for the given user, using request-scoped caching.
// The tree is cached per (orgID, userUID).
func (c *folderTreeCache) get(ctx context.Context, user identity.Requester) (*folder.FolderTree, error) {
cacheKey := fmt.Sprintf("folder_tree_%d_%s", user.GetOrgID(), user.GetUID())
if cached, ok := c.cache.Get(cacheKey); ok {
return cached.(*folder.FolderTree), nil
}
// Get folders accessible to this user
folders, err := c.folderSvc.GetFolders(ctx, folder.GetFoldersQuery{
OrgID: user.GetOrgID(),
SignedInUser: user,
})
if err != nil {
return nil, fmt.Errorf("failed to list accessible folders: %w", err)
}
// Build tree from accessible folders
tree := folder.NewFolderTree(folders)
c.cache.Set(cacheKey, tree, 0)
return tree, nil
}

View file

@ -0,0 +1,167 @@
package libraryelements
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util/testutil"
)
// trackingFolderService wraps a folder.Service and tracks how many times GetFolders is called.
type trackingFolderService struct {
*foldertest.FakeService
getFoldersCallCount atomic.Int32
}
func newTrackingFolderService() *trackingFolderService {
return &trackingFolderService{
FakeService: foldertest.NewFakeService(),
}
}
func (s *trackingFolderService) GetFolders(ctx context.Context, q folder.GetFoldersQuery) ([]*folder.Folder, error) {
s.getFoldersCallCount.Add(1)
return s.FakeService.GetFolders(ctx, q)
}
func (s *trackingFolderService) GetCallCount() int {
return int(s.getFoldersCallCount.Load())
}
func TestIntegration_FolderTreeCache(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
t.Run("GetAll calls GetFolders once and caches across requests", func(t *testing.T) {
sc := setupTestScenario(t)
// Create multiple library panels
for i := 0; i < 5; i++ {
// nolint:staticcheck
command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Panel "+string(rune('A'+i)))
sc.reqContext.Req.Body = mockRequestBody(command)
resp := sc.service.createHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
}
// Replace folder service with tracking one
trackingSvc := newTrackingFolderService()
trackingSvc.ExpectedFolders = []*folder.Folder{sc.folder}
trackingSvc.AddFolder(sc.folder)
originalFolderSvc := sc.service.folderService
sc.service.folderService = trackingSvc
sc.service.treeCache = newFolderTreeCache(trackingSvc)
defer func() { sc.service.folderService = originalFolderSvc }()
// First request
resp := sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryElementsSearch
err := json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
require.Equal(t, int64(5), result.Result.TotalCount)
assert.Equal(t, 1, trackingSvc.GetCallCount(), "GetFolders should be called once for tree build")
// Second request reuses global cache
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
assert.Equal(t, 1, trackingSvc.GetCallCount(), "Second request should reuse cached tree")
})
}
func TestFolderTreeCache_Unit(t *testing.T) {
t.Run("builds tree from GetFolders result", func(t *testing.T) {
sc := setupTestScenario(t)
fakeSvc := foldertest.NewFakeService()
fakeSvc.ExpectedFolders = []*folder.Folder{
{UID: "folder-a", Title: "Folder A", OrgID: 1},
{UID: "folder-b", Title: "Folder B", OrgID: 1, ParentUID: "folder-a"},
}
cache := newFolderTreeCache(fakeSvc)
tree, err := cache.get(context.Background(), sc.reqContext.SignedInUser)
require.NoError(t, err)
require.NotNil(t, tree)
assert.True(t, tree.Contains("folder-a"))
assert.True(t, tree.Contains("folder-b"))
assert.Equal(t, "Folder A", tree.GetTitle("folder-a"))
assert.Equal(t, "Folder B", tree.GetTitle("folder-b"))
})
t.Run("caches tree and returns same instance on repeated calls", func(t *testing.T) {
sc := setupTestScenario(t)
trackingSvc := newTrackingFolderService()
trackingSvc.ExpectedFolders = []*folder.Folder{
{UID: "folder-a", Title: "Folder A", OrgID: 1},
}
cache := newFolderTreeCache(trackingSvc)
ctx := context.Background()
tree1, err := cache.get(ctx, sc.reqContext.SignedInUser)
require.NoError(t, err)
assert.Equal(t, 1, trackingSvc.GetCallCount())
tree2, err := cache.get(ctx, sc.reqContext.SignedInUser)
require.NoError(t, err)
assert.Equal(t, 1, trackingSvc.GetCallCount(), "Should not call GetFolders again within TTL")
assert.Same(t, tree1, tree2, "Should return same cached tree instance")
})
t.Run("different user gets separate cache entry", func(t *testing.T) {
sc := setupTestScenario(t)
trackingSvc := newTrackingFolderService()
trackingSvc.ExpectedFolders = []*folder.Folder{
{UID: "folder-a", Title: "Folder A", OrgID: 1},
}
cache := newFolderTreeCache(trackingSvc)
ctx := context.Background()
// First user fetches and caches tree
_, err := cache.get(ctx, sc.reqContext.SignedInUser)
require.NoError(t, err)
assert.Equal(t, 1, trackingSvc.GetCallCount())
// Same user reuses cache
_, err = cache.get(ctx, sc.reqContext.SignedInUser)
require.NoError(t, err)
assert.Equal(t, 1, trackingSvc.GetCallCount(), "Same user should reuse cache")
// Different user triggers a new GetFolders call
user2 := &user.SignedInUser{
UserID: 999,
UserUID: "different-user-uid",
OrgID: sc.reqContext.GetOrgID(),
OrgRole: org.RoleViewer,
}
_, err = cache.get(ctx, user2)
require.NoError(t, err)
assert.Equal(t, 2, trackingSvc.GetCallCount(), "Different user should trigger a new GetFolders call")
})
}
func TestCacheKey(t *testing.T) {
t.Run("withCache adds cache key to context", func(t *testing.T) {
ctx := withCache(context.Background())
assert.True(t, hasCache(ctx))
})
t.Run("hasCache returns false when not set", func(t *testing.T) {
assert.False(t, hasCache(context.Background()))
})
}

View file

@ -302,7 +302,8 @@ func (l *LibraryElementService) DeleteLibraryElement(c context.Context, signedIn
}
// getLibraryElements gets a Library Element where param == value
func (l *LibraryElementService) getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser identity.Requester, params []Pair, features featuremgmt.FeatureToggles, cmd model.GetLibraryElementCommand) ([]model.LibraryElementDTO, error) {
// tree can be nil, in which case folder info will be fetched individually
func (l *LibraryElementService) getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser identity.Requester, params []Pair, features featuremgmt.FeatureToggles, cmd model.GetLibraryElementCommand, tree *folder.FolderTree) ([]model.LibraryElementDTO, error) {
if len(params) < 1 {
return nil, fmt.Errorf("expected at least one parameter pair")
}
@ -341,10 +342,28 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D
leDtos := make([]model.LibraryElementDTO, len(libraryElements))
for i, libraryElement := range libraryElements {
// nolint:staticcheck
f, err := l.folderService.Get(c, &folder.GetFolderQuery{OrgID: signedInUser.GetOrgID(), ID: &libraryElement.FolderID, SignedInUser: signedInUser})
if err != nil {
return []model.LibraryElementDTO{}, err
var folderUID, folderTitle string
var cacheHit bool
if tree != nil {
var fd folder.FolderNode
fd, cacheHit = tree.GetByID(libraryElement.FolderID) // nolint:staticcheck
if cacheHit {
folderTitle = fd.Title
folderUID = fd.UID
}
}
// Fetch folder from service if tree is nil, or if it doesn't have the folder info (e.g. due to a recent creation)
if tree == nil || !cacheHit {
// nolint:staticcheck
f, err := l.folderService.Get(c, &folder.GetFolderQuery{OrgID: signedInUser.GetOrgID(), ID: &libraryElement.FolderID, SignedInUser: signedInUser})
if err != nil {
return []model.LibraryElementDTO{}, err
}
folderTitle = f.Title
folderUID = f.UID
if f.ID == 0 { // nolint:staticcheck
folderUID = ac.GeneralFolderUID
}
}
var updatedModel json.RawMessage
if libraryElement.Kind == int64(model.PanelElement) {
@ -355,10 +374,6 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D
}
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc()
folderUID := f.UID
if f.ID == 0 { // nolint:staticcheck
folderUID = ac.GeneralFolderUID
}
leDtos[i] = model.LibraryElementDTO{
ID: libraryElement.ID,
OrgID: libraryElement.OrgID,
@ -372,7 +387,7 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D
Model: updatedModel,
Version: libraryElement.Version,
Meta: model.LibraryElementDTOMeta{
FolderName: f.Title,
FolderName: folderTitle,
FolderUID: folderUID,
ConnectedDashboards: libraryElement.ConnectedDashboards,
Created: libraryElement.Created,
@ -395,8 +410,9 @@ func (l *LibraryElementService) getLibraryElements(c context.Context, store db.D
}
// getLibraryElementByUid gets a Library Element by uid.
func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) {
libraryElements, err := l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.GetOrgID()}, {key: "uid", value: cmd.UID}}, l.features, cmd)
// tree can be nil, in which case folder info will be fetched individually
func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand, tree *folder.FolderTree) (model.LibraryElementDTO, error) {
libraryElements, err := l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.GetOrgID()}, {key: "uid", value: cmd.UID}}, l.features, cmd, tree)
if err != nil {
return model.LibraryElementDTO{}, err
}
@ -409,11 +425,10 @@ func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signed
// getLibraryElementByName gets a Library Element by name.
func (l *LibraryElementService) getLibraryElementsByName(c context.Context, signedInUser identity.Requester, name string) ([]model.LibraryElementDTO, error) {
return l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.GetOrgID()}, {"name", name}}, l.features,
model.GetLibraryElementCommand{
FolderName: dashboards.RootFolderName,
Name: name,
})
return l.getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.GetOrgID()}, {"name", name}}, l.features, model.GetLibraryElementCommand{
FolderName: dashboards.RootFolderName,
Name: name,
}, nil)
}
// getAllLibraryElements gets all Library Elements.
@ -483,32 +498,22 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.LibraryElements).Inc()
retDTOs := make([]model.LibraryElementDTO, 0)
// getting all folders a user can see
fs, err := l.folderService.GetFolders(c, folder.GetFoldersQuery{OrgID: signedInUser.GetOrgID(), SignedInUser: signedInUser})
// Get the folder tree filtered by user permissions (cached per user per request)
folderTree, err := l.treeCache.get(c, signedInUser)
if err != nil {
return err
}
// Every signed in user can see the general folder. The general folder might have "general" or the empty string as its UID.
// Using a map for O(1) lookup instead of O(n) slice iteration
folderUIDSet := make(map[string]bool, len(fs)+2)
folderUIDSet["general"] = true
folderUIDSet[""] = true
folderMap := make(map[string]string, len(fs))
for _, f := range fs {
folderUIDSet[f.UID] = true
folderMap[f.UID] = f.Title
}
// if the user is not an admin, we need to filter out elements that are not in folders the user can see
// Filter elements based on folder access using the tree
for _, element := range elements {
if !signedInUser.HasRole(org.RoleAdmin) {
if !folderUIDSet[element.FolderUID] {
if !folderTree.Contains(element.FolderUID) {
continue
}
}
if folderMap[element.FolderUID] == "" {
folderMap[element.FolderUID] = dashboards.RootFolderName
title := folderTree.GetTitle(element.FolderUID)
if title == "" {
title = dashboards.RootFolderName
}
retDTOs = append(retDTOs, model.LibraryElementDTO{
ID: element.ID,
@ -523,7 +528,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
Model: element.Model,
Version: element.Version,
Meta: model.LibraryElementDTOMeta{
FolderName: folderMap[element.FolderUID],
FolderName: title,
FolderUID: element.FolderUID,
ConnectedDashboards: element.ConnectedDashboards,
Created: element.Created,
@ -577,7 +582,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
if !signedInUser.HasRole(org.RoleAdmin) {
totalCount = 0
for _, element := range libraryElements {
if folderUIDSet[element.FolderUID] {
if folderTree.Contains(element.FolderUID) {
totalCount++
}
}

View file

@ -29,6 +29,7 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
features: features,
AccessControl: ac,
k8sHandler: newLibraryElementsK8sHandler(cfg, clientConfigProvider, folderService, userService, dashboardsService),
treeCache: newFolderTreeCache(folderService),
}
l.registerAPIEndpoints()
@ -61,13 +62,14 @@ type LibraryElementService struct {
features featuremgmt.FeatureToggles
AccessControl accesscontrol.AccessControl
k8sHandler *libraryElementsK8sHandler
treeCache *folderTreeCache
}
var _ Service = (*LibraryElementService)(nil)
// GetElement gets an element from a UID.
func (l *LibraryElementService) GetElement(c context.Context, signedInUser identity.Requester, cmd model.GetLibraryElementCommand) (model.LibraryElementDTO, error) {
return l.getLibraryElementByUid(c, signedInUser, cmd)
return l.getLibraryElementByUid(c, signedInUser, cmd, nil)
}
// GetElementsForDashboard gets all connected elements for a specific dashboard.

View file

@ -365,6 +365,7 @@ func setupTestScenario(t *testing.T) scenarioContext {
dashboardsService: dashService,
AccessControl: ac,
log: log.NewNopLogger(),
treeCache: newFolderTreeCache(folderSvc),
}
service.AccessControl.RegisterScopeAttributeResolver(LibraryPanelUIDScopeResolver(&service, folderSvc))

View file

@ -7,11 +7,14 @@ import (
"testing"
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
@ -434,3 +437,173 @@ func TestIntegrationLibraryPanelConnectionsWithFolderAccess(t *testing.T) {
})
}
}
// folderHierarchySetup holds the common test data for folder hierarchy tests.
type folderHierarchySetup struct {
ctx TestContext
parentFolder *folder.Folder
childFolder *folder.Folder
grandchildFolder *folder.Folder
libraryElements []string
testUser apis.User
}
// setupFolderHierarchy creates a nested folder structure with library elements and a test user.
func setupFolderHierarchy(t *testing.T, helper *apis.K8sTestHelper, dualWriterMode rest.DualWriterMode) folderHierarchySetup {
t.Helper()
ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
// Create a nested folder structure: parentFolder -> childFolder -> grandchildFolder
parentFolder, err := createFolder(t, ctx.Helper, ctx.AdminUser, "ParentFolder")
require.NoError(t, err)
require.NotNil(t, parentFolder)
childFolder, err := createSubFolder(t, ctx.Helper, ctx.AdminUser, "ChildFolder", parentFolder.UID)
require.NoError(t, err)
require.NotNil(t, childFolder)
grandchildFolder, err := createSubFolder(t, ctx.Helper, ctx.AdminUser, "GrandchildFolder", childFolder.UID)
require.NoError(t, err)
require.NotNil(t, grandchildFolder)
libraryElements := make([]string, 0, 3)
for _, f := range []*folder.Folder{parentFolder, childFolder, grandchildFolder} {
libraryElement, err := createLibraryElement(t, ctx, ctx.AdminUser, "Library Element in "+f.Title, f.UID, nil)
require.NoError(t, err)
libraryElements = append(libraryElements, libraryElement)
}
t.Cleanup(func() {
for _, uid := range libraryElements {
err := deleteLibraryElement(t, ctx, ctx.AdminUser, uid)
require.NoError(t, err)
}
})
testUser := ctx.Helper.CreateUser("hierarchy-test-user", "Org1", org.RoleViewer, nil)
return folderHierarchySetup{
ctx: ctx,
parentFolder: parentFolder,
childFolder: childFolder,
grandchildFolder: grandchildFolder,
libraryElements: libraryElements,
testUser: testUser,
}
}
// getVisibleLibraryElementUIDs returns the UIDs and total count of library elements visible to the user.
func getVisibleLibraryElementUIDs(t *testing.T, ctx *TestContext, user apis.User) ([]string, int) {
t.Helper()
listData, err := getDashboardViaHTTP(t, ctx, "/api/library-elements", user)
require.NoError(t, err)
require.NotNil(t, listData)
result := listData["result"].(map[string]interface{})
elements := result["elements"].([]interface{})
totalCount := int(result["totalCount"].(float64))
visibleUIDs := make([]string, 0, len(elements))
for _, elem := range elements {
elemMap := elem.(map[string]interface{})
if uid, ok := elemMap["uid"].(string); ok {
visibleUIDs = append(visibleUIDs, uid)
}
}
return visibleUIDs, totalCount
}
// TestIntegrationLibraryElementFolderHierarchy tests that permissions are correctly propagated in a folder hierarchy.
// Each sub-test uses its own K8sTestHelper to ensure independent folder tree caches.
func TestIntegrationLibraryElementFolderHierarchy(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode5}
for _, dualWriterMode := range dualWriterModes {
opts := testinfra.GrafanaOpts{
DisableDataMigrations: true,
DisableAnonymous: true,
EnableFeatureToggles: []string{
"kubernetesLibraryPanels",
},
UnifiedStorageEnableSearch: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: dualWriterMode,
},
"folders.folder.grafana.app": {
DualWriterMode: dualWriterMode,
},
},
DisableAuthZClientCache: true,
}
// Test 1: Parent folder access grants access to child folder library elements (inherited permissions)
t.Run(fmt.Sprintf("DualWriterMode %d/parent access grants child access", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, opts)
t.Cleanup(helper.Shutdown)
s := setupFolderHierarchy(t, helper, dualWriterMode)
// Give user access to the parent folder
setResourceUserPermission(t, s.ctx, s.ctx.AdminUser, false, s.parentFolder.UID, addUserPermission(t, nil, s.testUser, ResourcePermissionLevelView))
visibleUIDs, totalCount := getVisibleLibraryElementUIDs(t, &s.ctx, s.testUser)
// User with parent folder access should see ALL library elements
require.Contains(t, visibleUIDs, s.libraryElements[0])
require.Contains(t, visibleUIDs, s.libraryElements[1])
require.Contains(t, visibleUIDs, s.libraryElements[2])
require.Len(t, visibleUIDs, 3)
require.Equal(t, 3, totalCount)
})
// Test 2: Child folder access does NOT grant access to parent folder library elements (no reverse inheritance)
t.Run(fmt.Sprintf("DualWriterMode %d/child access does not grant parent access", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, opts)
t.Cleanup(helper.Shutdown)
s := setupFolderHierarchy(t, helper, dualWriterMode)
// Remove default viewer access to parent folder so only explicit permissions apply
setResourceUserPermission(t, s.ctx, s.ctx.AdminUser, false, s.parentFolder.UID, []ResourcePermissionSetting{})
// Give user access to ONLY the grandchild folder
setResourceUserPermission(t, s.ctx, s.ctx.AdminUser, false, s.grandchildFolder.UID, addUserPermission(t, nil, s.testUser, ResourcePermissionLevelView))
visibleUIDs, totalCount := getVisibleLibraryElementUIDs(t, &s.ctx, s.testUser)
// User should ONLY see the library element in the grandchild folder
require.Contains(t, visibleUIDs, s.libraryElements[2])
require.NotContains(t, visibleUIDs, s.libraryElements[1])
require.NotContains(t, visibleUIDs, s.libraryElements[0])
require.Len(t, visibleUIDs, 1)
require.Equal(t, 1, totalCount)
})
}
}
// createSubFolder creates a folder with a parent folder
func createSubFolder(t *testing.T, helper *apis.K8sTestHelper, user apis.User, title string, parentUID string) (*folder.Folder, error) {
t.Helper()
folderClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: user,
Namespace: helper.Namespacer(user.Identity.GetOrgID()),
GVR: getFolderGVR(),
})
folderObj := createFolderObject(t, title, helper.Namespacer(user.Identity.GetOrgID()), parentUID)
createdFolder, err := folderClient.Resource.Create(context.Background(), folderObj, v1.CreateOptions{})
if err != nil {
return nil, err
}
apis.AwaitZanzanaReconcileNext(t, helper)
meta, _ := utils.MetaAccessor(createdFolder)
return &folder.Folder{
UID: createdFolder.GetName(),
Title: meta.FindTitle(""),
ParentUID: parentUID,
}, nil
}