mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 22:02:48 +08:00
search: handle "sharedwithme" use-case in both legacy/US modes (#100286)
* handle "sharedwithme" use-case in both legacy/US modes * display "Shared with me" as location in dashboard list * fix missing "TotalHits" prop in mode 2
This commit is contained in:
@ -209,6 +209,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
|
||||
})
|
||||
}
|
||||
|
||||
list.TotalHits = int64(len(list.Results.Rows))
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
|
@ -54,6 +54,7 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
||||
require.NotNil(t, resp)
|
||||
searchFields := resource.StandardSearchFields()
|
||||
require.Equal(t, &resource.ResourceSearchResponse{
|
||||
TotalHits: 2,
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
searchFields.Field(resource.SEARCH_FIELD_TITLE),
|
||||
|
@ -3,6 +3,7 @@ package dashboard
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@ -25,7 +26,10 @@ import (
|
||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
foldermodel "github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/util/errhttp"
|
||||
@ -33,17 +37,19 @@ import (
|
||||
|
||||
// The DTO returns everything the UI needs in a single request
|
||||
type SearchHandler struct {
|
||||
log log.Logger
|
||||
client func(context.Context) resource.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
log log.Logger
|
||||
client func(context.Context) resource.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
}
|
||||
|
||||
func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient) *SearchHandler {
|
||||
func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient, features featuremgmt.FeatureToggles) *SearchHandler {
|
||||
searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, unified.GetResourceClient, legacyDashboardSearcher)
|
||||
return &SearchHandler{
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.dashboards.search"),
|
||||
tracer: tracer,
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.dashboards.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,19 +258,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
searchRequest.Fields = fields
|
||||
|
||||
// Add the folder constraint. Note this does not do recursive search
|
||||
folder := queryParams.Get("folder")
|
||||
if folder != "" {
|
||||
if folder == rootFolder {
|
||||
folder = "" // root folder is empty in the search index
|
||||
}
|
||||
searchRequest.Options.Fields = []*resource.Requirement{{
|
||||
Key: "folder",
|
||||
Operator: "=",
|
||||
Values: []string{folder},
|
||||
}}
|
||||
}
|
||||
|
||||
types := queryParams["type"]
|
||||
var federate *resource.ResourceKey
|
||||
switch len(types) {
|
||||
@ -329,7 +322,33 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// The names filter
|
||||
if names, ok := queryParams["name"]; ok {
|
||||
names := queryParams["name"]
|
||||
|
||||
// Add the folder constraint. Note this does not do recursive search
|
||||
folder := queryParams.Get("folder")
|
||||
if folder == foldermodel.SharedWithMeFolderUID {
|
||||
dashboardUIDs, err := s.getDashboardsUIDsSharedWithUser(ctx, user)
|
||||
if err != nil {
|
||||
errhttp.Write(ctx, err, w)
|
||||
return
|
||||
}
|
||||
|
||||
// hijacks the "name" query param to only search for shared dashboard UIDs
|
||||
if len(dashboardUIDs) > 0 {
|
||||
names = append(names, dashboardUIDs...)
|
||||
}
|
||||
} else if folder != "" {
|
||||
if folder == rootFolder {
|
||||
folder = "" // root folder is empty in the search index
|
||||
}
|
||||
searchRequest.Options.Fields = []*resource.Requirement{{
|
||||
Key: "folder",
|
||||
Operator: "=",
|
||||
Values: []string{folder},
|
||||
}}
|
||||
}
|
||||
|
||||
if len(names) > 0 {
|
||||
if searchRequest.Options.Fields == nil {
|
||||
searchRequest.Options.Fields = []*resource.Requirement{}
|
||||
}
|
||||
@ -378,3 +397,108 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) {
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, user identity.Requester) ([]string, error) {
|
||||
if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// gets dashboards that the user was granted read access to
|
||||
permissions := user.GetPermissions()
|
||||
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
|
||||
dashboardUids := make([]string, 0)
|
||||
sharedDashboards := make([]string, 0)
|
||||
|
||||
for _, dashboardPermission := range dashboardPermissions {
|
||||
if dashboardUid, found := strings.CutPrefix(dashboardPermission, dashboards.ScopeDashboardsPrefix); found {
|
||||
if !slices.Contains(dashboardUids, dashboardUid) {
|
||||
dashboardUids = append(dashboardUids, dashboardUid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(dashboardUids) == 0 {
|
||||
return sharedDashboards, nil
|
||||
}
|
||||
|
||||
key, err := asResourceKey(user.GetNamespace(), dashboard.DASHBOARD_RESOURCE)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
dashboardSearchRequest := &resource.ResourceSearchRequest{
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(dashboardUids)),
|
||||
Options: &resource.ListOptions{
|
||||
Key: key,
|
||||
Fields: []*resource.Requirement{{
|
||||
Key: "name",
|
||||
Operator: "in",
|
||||
Values: dashboardUids,
|
||||
}},
|
||||
},
|
||||
}
|
||||
// get all dashboards user has access to, along with their parent folder uid
|
||||
dashboardResult, err := s.client(ctx).Search(ctx, dashboardSearchRequest)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
folderUidIdx := -1
|
||||
for i, col := range dashboardResult.Results.Columns {
|
||||
if col.Name == "folder" {
|
||||
folderUidIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if folderUidIdx == -1 {
|
||||
return sharedDashboards, fmt.Errorf("Error retrieving folder information")
|
||||
}
|
||||
|
||||
// populate list of unique folder UIDs in the list of dashboards user has read permissions
|
||||
allFolders := make([]string, 0)
|
||||
for _, dash := range dashboardResult.Results.Rows {
|
||||
folderUid := string(dash.Cells[folderUidIdx])
|
||||
if folderUid != "" && !slices.Contains(allFolders, folderUid) {
|
||||
allFolders = append(allFolders, folderUid)
|
||||
}
|
||||
}
|
||||
|
||||
// only folders the user has access to will be returned here
|
||||
folderKey, err := asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
folderSearchRequest := &resource.ResourceSearchRequest{
|
||||
Fields: []string{"folder"},
|
||||
Limit: int64(len(allFolders)),
|
||||
Options: &resource.ListOptions{
|
||||
Key: folderKey,
|
||||
Fields: []*resource.Requirement{{
|
||||
Key: "name",
|
||||
Operator: "in",
|
||||
Values: allFolders,
|
||||
}},
|
||||
},
|
||||
}
|
||||
foldersResult, err := s.client(ctx).Search(ctx, folderSearchRequest)
|
||||
if err != nil {
|
||||
return sharedDashboards, err
|
||||
}
|
||||
|
||||
foldersWithAccess := make([]string, 0, len(foldersResult.Results.Rows))
|
||||
for _, fold := range foldersResult.Results.Rows {
|
||||
foldersWithAccess = append(foldersWithAccess, fold.Key.Name)
|
||||
}
|
||||
|
||||
// add to sharedDashboards dashboards user has access to, but does NOT have access to it's parent folder
|
||||
for _, dash := range dashboardResult.Results.Rows {
|
||||
dashboardUid := dash.Key.Name
|
||||
folderUid := string(dash.Cells[folderUidIdx])
|
||||
if folderUid != "" && !slices.Contains(foldersWithAccess, folderUid) {
|
||||
sharedDashboards = append(sharedDashboards, dashboardUid)
|
||||
}
|
||||
}
|
||||
return sharedDashboards, nil
|
||||
}
|
||||
|
@ -12,6 +12,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
@ -31,7 +33,7 @@ func TestSearchFallback(t *testing.T) {
|
||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0},
|
||||
},
|
||||
}
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
|
||||
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -59,7 +61,7 @@ func TestSearchFallback(t *testing.T) {
|
||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1},
|
||||
},
|
||||
}
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
|
||||
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -87,7 +89,7 @@ func TestSearchFallback(t *testing.T) {
|
||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2},
|
||||
},
|
||||
}
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
|
||||
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -115,7 +117,7 @@ func TestSearchFallback(t *testing.T) {
|
||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3},
|
||||
},
|
||||
}
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
|
||||
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -143,7 +145,7 @@ func TestSearchFallback(t *testing.T) {
|
||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4},
|
||||
},
|
||||
}
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
|
||||
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -171,7 +173,7 @@ func TestSearchFallback(t *testing.T) {
|
||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
|
||||
},
|
||||
}
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
|
||||
searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
|
||||
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
@ -191,17 +193,19 @@ func TestSearchFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchHandler(t *testing.T) {
|
||||
// Create a mock client
|
||||
mockClient := &MockClient{}
|
||||
|
||||
// Initialize the search handler with the mock client
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: func(context.Context) resource.ResourceIndexClient { return mockClient },
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
}
|
||||
|
||||
t.Run("Multiple comma separated fields will be appended to default dashboard search fields", func(t *testing.T) {
|
||||
// Create a mock client
|
||||
mockClient := &MockClient{}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
// Initialize the search handler with the mock client
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: func(context.Context) resource.ResourceIndexClient { return mockClient },
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
@ -219,6 +223,18 @@ func TestSearchHandler(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Single field will be appended to default dashboard search fields", func(t *testing.T) {
|
||||
// Create a mock client
|
||||
mockClient := &MockClient{}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
// Initialize the search handler with the mock client
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: func(context.Context) resource.ResourceIndexClient { return mockClient },
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?field=field1", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
@ -236,6 +252,18 @@ func TestSearchHandler(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Passing no fields will search using default dashboard fields", func(t *testing.T) {
|
||||
// Create a mock client
|
||||
mockClient := &MockClient{}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
// Initialize the search handler with the mock client
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: func(context.Context) resource.ResourceIndexClient { return mockClient },
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
@ -253,6 +281,41 @@ func TestSearchHandler(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Sort - default sort by resource then title", func(t *testing.T) {
|
||||
rows := make([]*resource.ResourceTableRow, len(mockResults))
|
||||
for i, r := range mockResults {
|
||||
rows[i] = &resource.ResourceTableRow{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: r.Name,
|
||||
Resource: r.Resource,
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte(r.Value),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
mockResponse := &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{Name: resource.SEARCH_FIELD_TITLE},
|
||||
},
|
||||
Rows: rows,
|
||||
},
|
||||
}
|
||||
// Create a mock client
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resource.ResourceSearchResponse{mockResponse},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
// Initialize the search handler with the mock client
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: func(context.Context) resource.ResourceIndexClient { return mockClient },
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
@ -280,6 +343,125 @@ func TestSearchHandler(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||
t.Run("should bail out if FlagUnifiedStorageSearchPermissionFiltering is not enabled globally", func(t *testing.T) {
|
||||
mockClient := &MockClient{}
|
||||
|
||||
features := featuremgmt.WithFeatures()
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: func(context.Context) resource.ResourceIndexClient { return mockClient },
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
assert.Equal(t, mockClient.CallCount, 1)
|
||||
})
|
||||
|
||||
t.Run("should return the dashboards shared with the user", func(t *testing.T) {
|
||||
// dashboardSearchRequest
|
||||
mockResponse1 := &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "folder",
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "dashboardinroot",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{[]byte("")}, // root folder doesn't have uid
|
||||
},
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "dashboardinprivatefolder",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("privatefolder"), // folder uid
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "dashboardinpublicfolder",
|
||||
Resource: "dashboard",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte("publicfolder"), // folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// folderSearchRequest
|
||||
mockResponse2 := &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{
|
||||
Name: "folder",
|
||||
},
|
||||
},
|
||||
Rows: []*resource.ResourceTableRow{
|
||||
{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: "publicfolder",
|
||||
Resource: "folder",
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte(""), // root folder uid
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := &MockClient{
|
||||
MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2},
|
||||
}
|
||||
|
||||
features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering)
|
||||
searchHandler := SearchHandler{
|
||||
log: log.New("test", "test"),
|
||||
client: func(context.Context) resource.ResourceIndexClient { return mockClient },
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: features,
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
allPermissions := make(map[int64]map[string][]string)
|
||||
permissions := make(map[string][]string)
|
||||
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
|
||||
allPermissions[1] = permissions
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
|
||||
|
||||
searchHandler.DoSearch(rr, req)
|
||||
|
||||
assert.Equal(t, mockClient.CallCount, 3)
|
||||
|
||||
// first call gets all dashboards user has permission for
|
||||
firstCall := mockClient.MockCalls[0]
|
||||
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
|
||||
// second call gets folders associated with the previous dashboards
|
||||
secondCall := mockClient.MockCalls[1]
|
||||
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
|
||||
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
|
||||
// permission to read
|
||||
thirdCall := mockClient.MockCalls[2]
|
||||
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
|
||||
})
|
||||
}
|
||||
|
||||
// MockClient implements the ResourceIndexClient interface for testing
|
||||
type MockClient struct {
|
||||
resource.ResourceIndexClient
|
||||
@ -287,6 +469,10 @@ type MockClient struct {
|
||||
|
||||
// Capture the last SearchRequest for assertions
|
||||
LastSearchRequest *resource.ResourceSearchRequest
|
||||
|
||||
MockResponses []*resource.ResourceSearchResponse
|
||||
MockCalls []*resource.ResourceSearchRequest
|
||||
CallCount int
|
||||
}
|
||||
|
||||
type MockResult struct {
|
||||
@ -320,28 +506,16 @@ var mockResults = []MockResult{
|
||||
|
||||
func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
|
||||
m.LastSearchRequest = in
|
||||
m.MockCalls = append(m.MockCalls, in)
|
||||
|
||||
rows := make([]*resource.ResourceTableRow, len(mockResults))
|
||||
for i, r := range mockResults {
|
||||
rows[i] = &resource.ResourceTableRow{
|
||||
Key: &resource.ResourceKey{
|
||||
Name: r.Name,
|
||||
Resource: r.Resource,
|
||||
},
|
||||
Cells: [][]byte{
|
||||
[]byte(r.Value),
|
||||
},
|
||||
}
|
||||
var response *resource.ResourceSearchResponse
|
||||
if m.CallCount < len(m.MockResponses) {
|
||||
response = m.MockResponses[m.CallCount]
|
||||
}
|
||||
|
||||
return &resource.ResourceSearchResponse{
|
||||
Results: &resource.ResourceTable{
|
||||
Columns: []*resource.ResourceTableColumnDefinition{
|
||||
{Name: resource.SEARCH_FIELD_TITLE},
|
||||
},
|
||||
Rows: rows,
|
||||
},
|
||||
}, nil
|
||||
m.CallCount = m.CallCount + 1
|
||||
|
||||
return response, nil
|
||||
}
|
||||
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
|
||||
return nil, nil
|
||||
|
@ -82,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
|
||||
features: features,
|
||||
accessControl: accessControl,
|
||||
unified: unified,
|
||||
search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher),
|
||||
search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher, features),
|
||||
|
||||
legacy: &dashboard.DashboardStorage{
|
||||
Resource: dashboardv0alpha1.DashboardResourceInfo,
|
||||
|
@ -194,17 +194,34 @@ export const generateColumns = (
|
||||
if (!info && p === 'general') {
|
||||
info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' };
|
||||
}
|
||||
return info ? (
|
||||
<a key={p} href={info.url} className={styles.locationItem}>
|
||||
<Icon name={getIconForKind(info.kind)} />
|
||||
|
||||
<Text variant="body" truncate>
|
||||
{info.name}
|
||||
</Text>
|
||||
</a>
|
||||
) : (
|
||||
<span key={p}>{p}</span>
|
||||
);
|
||||
if (info) {
|
||||
const content = (
|
||||
<>
|
||||
<Icon name={getIconForKind(info.kind)} />
|
||||
|
||||
<Text variant="body" truncate>
|
||||
{info.name}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
if (info.url) {
|
||||
return (
|
||||
<a key={p} href={info.url} className={styles.locationItem}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={p} className={styles.locationItem}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span key={p}>{p}</span>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
@ -115,9 +115,6 @@ describe('Unified Storage Searcher', () => {
|
||||
.mockResolvedValueOnce(mockResults)
|
||||
.mockResolvedValueOnce(mockFolders);
|
||||
|
||||
const consoleWarn = jest.fn();
|
||||
jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn);
|
||||
|
||||
const query: SearchQuery = {
|
||||
query: 'test',
|
||||
limit: 50,
|
||||
@ -127,14 +124,15 @@ describe('Unified Storage Searcher', () => {
|
||||
|
||||
const response = await searcher.search(query);
|
||||
|
||||
expect(response.view.length).toBe(1);
|
||||
expect(response.view.get(0).title).toBe('DB 2');
|
||||
expect(response.view.length).toBe(2);
|
||||
expect(response.view.get(0).title).toBe('DB 1');
|
||||
expect(response.view.get(0).folder).toBe('sharedwithme');
|
||||
expect(response.view.get(1).title).toBe('DB 2');
|
||||
|
||||
const df = response.view.dataFrame;
|
||||
const locationInfo = df.meta?.custom?.locationInfo;
|
||||
expect(locationInfo).toBeDefined();
|
||||
expect(locationInfo?.folder2.name).toBe('Folder 2');
|
||||
expect(consoleWarn).toHaveBeenCalled();
|
||||
expect(mockSearcher.search).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
|
@ -204,15 +204,19 @@ export class UnifiedSearcher implements GrafanaSearcher {
|
||||
if (!hasMissing) {
|
||||
return rsp;
|
||||
}
|
||||
// we still have results here with folders we can't find
|
||||
// filter the results since we probably don't have access to that folder
|
||||
|
||||
const locationInfo = await this.locationInfo;
|
||||
const hits = rsp.hits.filter((hit) => {
|
||||
if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) {
|
||||
return true;
|
||||
const hits = rsp.hits.map((hit) => {
|
||||
if (hit.folder === undefined) {
|
||||
return { ...hit, location: 'general', folder: 'general' };
|
||||
}
|
||||
console.warn('Dropping search hit with missing folder', hit);
|
||||
return false;
|
||||
|
||||
// this means user has permission to see this dashboard, but not the folder contents
|
||||
if (locationInfo[hit.folder] === undefined) {
|
||||
return { ...hit, location: 'sharedwithme', folder: 'sharedwithme' };
|
||||
}
|
||||
|
||||
return hit;
|
||||
});
|
||||
const totalHits = rsp.totalHits - (rsp.hits.length - hits.length);
|
||||
return { ...rsp, hits, totalHits };
|
||||
@ -370,6 +374,11 @@ async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
||||
name: 'Dashboards',
|
||||
url: '/dashboards',
|
||||
}, // share location info with everyone
|
||||
sharedwithme: {
|
||||
kind: 'sharedwithme',
|
||||
name: 'Shared with me',
|
||||
url: '',
|
||||
},
|
||||
};
|
||||
for (const hit of rsp.hits) {
|
||||
locationInfo[hit.name] = {
|
||||
|
@ -49,6 +49,10 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
||||
return isOpen ? 'folder-open' : 'folder';
|
||||
}
|
||||
|
||||
if (kind === 'sharedwithme') {
|
||||
return 'users-alt';
|
||||
}
|
||||
|
||||
return 'question-circle';
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user