mirror of
https://github.com/grafana/grafana.git
synced 2026-02-18 18:20:52 -05:00
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:
parent
e6eb555c42
commit
b3fd56dc4e
10 changed files with 1054 additions and 42 deletions
183
pkg/services/folder/tree.go
Normal file
183
pkg/services/folder/tree.go
Normal 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
|
||||
}
|
||||
388
pkg/services/folder/tree_test.go
Normal file
388
pkg/services/folder/tree_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
63
pkg/services/libraryelements/cache.go
Normal file
63
pkg/services/libraryelements/cache.go
Normal 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
|
||||
}
|
||||
167
pkg/services/libraryelements/cache_test.go
Normal file
167
pkg/services/libraryelements/cache_test.go
Normal 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()))
|
||||
})
|
||||
}
|
||||
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue