mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 18:26:52 +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
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,6 +54,7 @@ func TestDashboardSearchClient_Search(t *testing.T) {
|
|||||||
require.NotNil(t, resp)
|
require.NotNil(t, resp)
|
||||||
searchFields := resource.StandardSearchFields()
|
searchFields := resource.StandardSearchFields()
|
||||||
require.Equal(t, &resource.ResourceSearchResponse{
|
require.Equal(t, &resource.ResourceSearchResponse{
|
||||||
|
TotalHits: 2,
|
||||||
Results: &resource.ResourceTable{
|
Results: &resource.ResourceTable{
|
||||||
Columns: []*resource.ResourceTableColumnDefinition{
|
Columns: []*resource.ResourceTableColumnDefinition{
|
||||||
searchFields.Field(resource.SEARCH_FIELD_TITLE),
|
searchFields.Field(resource.SEARCH_FIELD_TITLE),
|
||||||
|
@ -3,6 +3,7 @@ package dashboard
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
@ -25,7 +26,10 @@ import (
|
|||||||
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
"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"
|
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/setting"
|
||||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||||
"github.com/grafana/grafana/pkg/util/errhttp"
|
"github.com/grafana/grafana/pkg/util/errhttp"
|
||||||
@ -33,17 +37,19 @@ import (
|
|||||||
|
|
||||||
// The DTO returns everything the UI needs in a single request
|
// The DTO returns everything the UI needs in a single request
|
||||||
type SearchHandler struct {
|
type SearchHandler struct {
|
||||||
log log.Logger
|
log log.Logger
|
||||||
client func(context.Context) resource.ResourceIndexClient
|
client func(context.Context) resource.ResourceIndexClient
|
||||||
tracer trace.Tracer
|
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)
|
searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, unified.GetResourceClient, legacyDashboardSearcher)
|
||||||
return &SearchHandler{
|
return &SearchHandler{
|
||||||
client: searchClient,
|
client: searchClient,
|
||||||
log: log.New("grafana-apiserver.dashboards.search"),
|
log: log.New("grafana-apiserver.dashboards.search"),
|
||||||
tracer: tracer,
|
tracer: tracer,
|
||||||
|
features: features,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,19 +258,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
searchRequest.Fields = fields
|
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"]
|
types := queryParams["type"]
|
||||||
var federate *resource.ResourceKey
|
var federate *resource.ResourceKey
|
||||||
switch len(types) {
|
switch len(types) {
|
||||||
@ -329,7 +322,33 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The names filter
|
// 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 {
|
if searchRequest.Options.Fields == nil {
|
||||||
searchRequest.Options.Fields = []*resource.Requirement{}
|
searchRequest.Options.Fields = []*resource.Requirement{}
|
||||||
}
|
}
|
||||||
@ -378,3 +397,108 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) {
|
|||||||
|
|
||||||
return key, nil
|
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/apiserver/rest"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"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/services/user"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||||
@ -31,7 +33,7 @@ func TestSearchFallback(t *testing.T) {
|
|||||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0},
|
"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)
|
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@ -59,7 +61,7 @@ func TestSearchFallback(t *testing.T) {
|
|||||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1},
|
"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)
|
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@ -87,7 +89,7 @@ func TestSearchFallback(t *testing.T) {
|
|||||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2},
|
"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)
|
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@ -115,7 +117,7 @@ func TestSearchFallback(t *testing.T) {
|
|||||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3},
|
"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)
|
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@ -143,7 +145,7 @@ func TestSearchFallback(t *testing.T) {
|
|||||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4},
|
"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)
|
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@ -171,7 +173,7 @@ func TestSearchFallback(t *testing.T) {
|
|||||||
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
|
"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)
|
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@ -191,17 +193,19 @@ func TestSearchFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchHandler(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) {
|
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()
|
rr := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil)
|
req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil)
|
||||||
req.Header.Add("content-type", "application/json")
|
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) {
|
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()
|
rr := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("GET", "/search?field=field1", nil)
|
req := httptest.NewRequest("GET", "/search?field=field1", nil)
|
||||||
req.Header.Add("content-type", "application/json")
|
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) {
|
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()
|
rr := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("GET", "/search", nil)
|
req := httptest.NewRequest("GET", "/search", nil)
|
||||||
req.Header.Add("content-type", "application/json")
|
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) {
|
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()
|
rr := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("GET", "/search", nil)
|
req := httptest.NewRequest("GET", "/search", nil)
|
||||||
req.Header.Add("content-type", "application/json")
|
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
|
// MockClient implements the ResourceIndexClient interface for testing
|
||||||
type MockClient struct {
|
type MockClient struct {
|
||||||
resource.ResourceIndexClient
|
resource.ResourceIndexClient
|
||||||
@ -287,6 +469,10 @@ type MockClient struct {
|
|||||||
|
|
||||||
// Capture the last SearchRequest for assertions
|
// Capture the last SearchRequest for assertions
|
||||||
LastSearchRequest *resource.ResourceSearchRequest
|
LastSearchRequest *resource.ResourceSearchRequest
|
||||||
|
|
||||||
|
MockResponses []*resource.ResourceSearchResponse
|
||||||
|
MockCalls []*resource.ResourceSearchRequest
|
||||||
|
CallCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockResult struct {
|
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) {
|
func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
|
||||||
m.LastSearchRequest = in
|
m.LastSearchRequest = in
|
||||||
|
m.MockCalls = append(m.MockCalls, in)
|
||||||
|
|
||||||
rows := make([]*resource.ResourceTableRow, len(mockResults))
|
var response *resource.ResourceSearchResponse
|
||||||
for i, r := range mockResults {
|
if m.CallCount < len(m.MockResponses) {
|
||||||
rows[i] = &resource.ResourceTableRow{
|
response = m.MockResponses[m.CallCount]
|
||||||
Key: &resource.ResourceKey{
|
|
||||||
Name: r.Name,
|
|
||||||
Resource: r.Resource,
|
|
||||||
},
|
|
||||||
Cells: [][]byte{
|
|
||||||
[]byte(r.Value),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &resource.ResourceSearchResponse{
|
m.CallCount = m.CallCount + 1
|
||||||
Results: &resource.ResourceTable{
|
|
||||||
Columns: []*resource.ResourceTableColumnDefinition{
|
return response, nil
|
||||||
{Name: resource.SEARCH_FIELD_TITLE},
|
|
||||||
},
|
|
||||||
Rows: rows,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
|
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
@ -82,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
|
|||||||
features: features,
|
features: features,
|
||||||
accessControl: accessControl,
|
accessControl: accessControl,
|
||||||
unified: unified,
|
unified: unified,
|
||||||
search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher),
|
search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher, features),
|
||||||
|
|
||||||
legacy: &dashboard.DashboardStorage{
|
legacy: &dashboard.DashboardStorage{
|
||||||
Resource: dashboardv0alpha1.DashboardResourceInfo,
|
Resource: dashboardv0alpha1.DashboardResourceInfo,
|
||||||
|
@ -194,17 +194,34 @@ export const generateColumns = (
|
|||||||
if (!info && p === 'general') {
|
if (!info && p === 'general') {
|
||||||
info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' };
|
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>
|
if (info) {
|
||||||
{info.name}
|
const content = (
|
||||||
</Text>
|
<>
|
||||||
</a>
|
<Icon name={getIconForKind(info.kind)} />
|
||||||
) : (
|
|
||||||
<span key={p}>{p}</span>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -115,9 +115,6 @@ describe('Unified Storage Searcher', () => {
|
|||||||
.mockResolvedValueOnce(mockResults)
|
.mockResolvedValueOnce(mockResults)
|
||||||
.mockResolvedValueOnce(mockFolders);
|
.mockResolvedValueOnce(mockFolders);
|
||||||
|
|
||||||
const consoleWarn = jest.fn();
|
|
||||||
jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn);
|
|
||||||
|
|
||||||
const query: SearchQuery = {
|
const query: SearchQuery = {
|
||||||
query: 'test',
|
query: 'test',
|
||||||
limit: 50,
|
limit: 50,
|
||||||
@ -127,14 +124,15 @@ describe('Unified Storage Searcher', () => {
|
|||||||
|
|
||||||
const response = await searcher.search(query);
|
const response = await searcher.search(query);
|
||||||
|
|
||||||
expect(response.view.length).toBe(1);
|
expect(response.view.length).toBe(2);
|
||||||
expect(response.view.get(0).title).toBe('DB 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 df = response.view.dataFrame;
|
||||||
const locationInfo = df.meta?.custom?.locationInfo;
|
const locationInfo = df.meta?.custom?.locationInfo;
|
||||||
expect(locationInfo).toBeDefined();
|
expect(locationInfo).toBeDefined();
|
||||||
expect(locationInfo?.folder2.name).toBe('Folder 2');
|
expect(locationInfo?.folder2.name).toBe('Folder 2');
|
||||||
expect(consoleWarn).toHaveBeenCalled();
|
|
||||||
expect(mockSearcher.search).toHaveBeenCalledTimes(3);
|
expect(mockSearcher.search).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -204,15 +204,19 @@ export class UnifiedSearcher implements GrafanaSearcher {
|
|||||||
if (!hasMissing) {
|
if (!hasMissing) {
|
||||||
return rsp;
|
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 locationInfo = await this.locationInfo;
|
||||||
const hits = rsp.hits.filter((hit) => {
|
const hits = rsp.hits.map((hit) => {
|
||||||
if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) {
|
if (hit.folder === undefined) {
|
||||||
return true;
|
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);
|
const totalHits = rsp.totalHits - (rsp.hits.length - hits.length);
|
||||||
return { ...rsp, hits, totalHits };
|
return { ...rsp, hits, totalHits };
|
||||||
@ -370,6 +374,11 @@ async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
|||||||
name: 'Dashboards',
|
name: 'Dashboards',
|
||||||
url: '/dashboards',
|
url: '/dashboards',
|
||||||
}, // share location info with everyone
|
}, // share location info with everyone
|
||||||
|
sharedwithme: {
|
||||||
|
kind: 'sharedwithme',
|
||||||
|
name: 'Shared with me',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
for (const hit of rsp.hits) {
|
for (const hit of rsp.hits) {
|
||||||
locationInfo[hit.name] = {
|
locationInfo[hit.name] = {
|
||||||
|
@ -49,6 +49,10 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
|||||||
return isOpen ? 'folder-open' : 'folder';
|
return isOpen ? 'folder-open' : 'folder';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === 'sharedwithme') {
|
||||||
|
return 'users-alt';
|
||||||
|
}
|
||||||
|
|
||||||
return 'question-circle';
|
return 'question-circle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user