package dashboards import ( "context" "encoding/json" "fmt" "io/fs" "math" "net/http" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" k8srest "k8s.io/client-go/rest" dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util/testutil" ) func TestIntegrationSearchDevDashboards(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) ctx := context.Background() helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableDataMigrations: true, AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, "folders.folder.grafana.app": {DualWriterMode: rest.Mode5}, }, UnifiedStorageEnableSearch: true, }) defer helper.Shutdown() // Create devenv dashboards from legacy API cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) cfg.GroupVersion = &dashboardV0.GroupVersion adminClient, err := k8srest.RESTClientFor(cfg) require.NoError(t, err) adminClient.Get() fileCount := 0 devenv := "../../../../devenv/dev-dashboards/panel-timeseries" err = filepath.WalkDir(devenv, func(p string, d fs.DirEntry, e error) error { require.NoError(t, err) if d.IsDir() || filepath.Ext(d.Name()) != ".json" { return nil } // use the filename as UID uid := strings.TrimSuffix(d.Name(), ".json") if len(uid) > 40 { uid = uid[:40] // avoid uid too long, max 40 characters } // nolint:gosec data, err := os.ReadFile(p) require.NoError(t, err) cmd := dashboards.SaveDashboardCommand{ Dashboard: &simplejson.Json{}, Overwrite: true, } err = cmd.Dashboard.FromDB(data) require.NoError(t, err) cmd.Dashboard.Set("id", nil) cmd.Dashboard.Set("uid", uid) data, err = json.Marshal(cmd) require.NoError(t, err) var statusCode int result := adminClient.Post().AbsPath("api", "dashboards", "db"). Body(data). SetHeader("Content-type", "application/json"). Do(ctx). StatusCode(&statusCode) require.NoError(t, result.Error(), "file: [%d] %s [status:%d]", fileCount, d.Name(), statusCode) require.Equal(t, int(http.StatusOK), statusCode) fileCount++ return nil }) require.NoError(t, err) require.Equal(t, 16, fileCount, "file count from %s", devenv) // Helper to call search callSearch := func(user apis.User, params map[string]string) dashboardV0.SearchResults { require.NotNil(t, user) ns := user.Identity.GetNamespace() cfg := dynamic.ConfigFor(user.NewRestConfig()) cfg.GroupVersion = &dashboardV0.GroupVersion restClient, err := k8srest.RESTClientFor(cfg) require.NoError(t, err) var statusCode int req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). //Param("explain", "true") // helpful to understand which field made things match Param("limit", "1000"). Param("type", "dashboard") // Only search dashboards for k, v := range params { req = req.Param(k, v) } res := req.Do(ctx).StatusCode(&statusCode) require.NoError(t, res.Error()) require.Equal(t, int(http.StatusOK), statusCode) var sr dashboardV0.SearchResults raw, err := res.Raw() require.NoError(t, err) require.NoError(t, json.Unmarshal(raw, &sr)) // Normalize scores and query cost for snapshot comparison sr.QueryCost = 0 // this depends on the hardware sr.MaxScore = roundTo(sr.MaxScore, 3) for i := range sr.Hits { sr.Hits[i].Score = roundTo(sr.Hits[i].Score, 3) // 0.6250571494814442 -> 0.625 } return sr } // Compare a results to snapshots testCases := []struct { name string user apis.User params map[string]string }{ { name: "all", user: helper.Org1.Admin, }, { name: "query-single-word", user: helper.Org1.Admin, params: map[string]string{ "query": "stacking", }, }, { name: "query-multiple-words", user: helper.Org1.Admin, params: map[string]string{ "query": "graph softMin", // must match ALL terms }, }, { name: "with-text-panel", user: helper.Org1.Admin, params: map[string]string{ "field": "panel_types", // return panel types "panelType": "text", }, }, { name: "title-ngram-prefix", user: helper.Org1.Admin, params: map[string]string{ "query": "zer", // should match "Zero Decimals Y Ticks" }, }, { name: "title-ngram-middle-word", user: helper.Org1.Admin, params: map[string]string{ "query": "decim", // should match "Zero Decimals Y Ticks" }, }, } for i, tc := range testCases { t.Run(tc.name, func(t *testing.T) { res := callSearch(tc.user, tc.params) jj, err := json.MarshalIndent(res, "", " ") require.NoError(t, err) fname := fmt.Sprintf("testdata/searchV0/t%02d-%s.json", i, tc.name) // nolint:gosec snapshot, err := os.ReadFile(fname) if err != nil { assert.Failf(t, "Failed to read snapshot", "file: %s", fname) err = os.WriteFile(fname, jj, 0o644) require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) return } if !assert.JSONEq(t, string(snapshot), string(jj)) { err = os.WriteFile(fname, jj, 0o644) require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) } }) } } func TestIntegrationSearchPermissionFiltering(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) // Only run for Unified Storage modes that support search (Mode3+) modes := []rest.DualWriterMode{rest.Mode3, rest.Mode4, rest.Mode5} for _, mode := range modes { runSearchPermissionTest(t, mode) } } func runSearchPermissionTest(t *testing.T, mode rest.DualWriterMode) { t.Run(fmt.Sprintf("search permission filtering with dual writer mode %d", mode), func(t *testing.T) { ctx := context.Background() helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableDataMigrations: true, AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": {DualWriterMode: mode}, "folders.folder.grafana.app": {DualWriterMode: mode}, }, UnifiedStorageEnableSearch: true, }) defer helper.Shutdown() // Create a folder via legacy API using Admin folderUID := "perm-test-folder" { cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) cfg.GroupVersion = &schema.GroupVersion{Group: "folder.grafana.app", Version: "v1beta1"} restClient, err := k8srest.RESTClientFor(cfg) require.NoError(t, err) var statusCode int body := []byte(fmt.Sprintf(`{"uid":"%s","title":"Permission Test Folder"}`, folderUID)) result := restClient.Post().AbsPath("api", "folders"). Body(body). SetHeader("Content-type", "application/json"). Do(ctx). StatusCode(&statusCode) require.NoError(t, result.Error()) require.Equal(t, int(http.StatusOK), statusCode) } // Set permissions: Viewer gets View, Editor gets Edit viewerID, err := identity.UserIdentifier(helper.Org1.Viewer.Identity.GetID()) require.NoError(t, err) editorID, err := identity.UserIdentifier(helper.Org1.Editor.Identity.GetID()) require.NoError(t, err) permissions := []ResourcePermissionSetting{ {UserID: &viewerID, Level: ResourcePermissionLevelView}, {UserID: &editorID, Level: ResourcePermissionLevelEdit}, } setFolderPermissions(t, helper, helper.Org1.Admin, folderUID, permissions) // Helper to call search callSearch := func(user apis.User, params string) dashboardV0.SearchResults { ns := user.Identity.GetNamespace() cfg := dynamic.ConfigFor(user.NewRestConfig()) cfg.GroupVersion = &schema.GroupVersion{Group: "dashboard.grafana.app", Version: "v0alpha1"} restClient, err := k8srest.RESTClientFor(cfg) require.NoError(t, err) var statusCode int req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). Param("limit", "1000"). Param("type", "folder") // Only search folders for _, kv := range strings.Split(params, "&") { if kv == "" { continue } parts := strings.SplitN(kv, "=", 2) if len(parts) == 2 { req = req.Param(parts[0], parts[1]) } } res := req.Do(ctx).StatusCode(&statusCode) require.NoError(t, res.Error()) require.Equal(t, int(http.StatusOK), statusCode) var sr dashboardV0.SearchResults raw, err := res.Raw() require.NoError(t, err) require.NoError(t, json.Unmarshal(raw, &sr)) return sr } // 1. Viewer searching without permission parameter should find it (has View access) { res := callSearch(helper.Org1.Viewer, "") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Viewer should find folder without permission parameter") } // 2. Viewer searching with permission=View should find it { res := callSearch(helper.Org1.Viewer, "permission=view") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Viewer should find folder with permission=view") } // 3. Viewer searching with permission=Edit should NOT find it { res := callSearch(helper.Org1.Viewer, "permission=edit") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.False(t, found, "Viewer should NOT find folder with permission=edit") } // 4. Editor searching with permission=Edit should find it { res := callSearch(helper.Org1.Editor, "permission=edit") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Editor should find folder with permission=edit") } // 5. Editor searching with permission=View should find it (Edit permission includes View) { res := callSearch(helper.Org1.Editor, "permission=view") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Editor should find folder with permission=view (Edit includes View)") } // 6. Editor searching without permission parameter should find it (has Edit access) { res := callSearch(helper.Org1.Editor, "") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Editor should find folder without permission parameter") } // 7. Admin searching with permission=View should find it (Admin has full access) { res := callSearch(helper.Org1.Admin, "permission=view") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Admin should find folder with permission=view") } // 8. Admin searching with permission=Edit should find it (Admin has full access) { res := callSearch(helper.Org1.Admin, "permission=edit") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Admin should find folder with permission=edit") } // 9. Admin searching with permission=Admin should find it (Admin has full access) { res := callSearch(helper.Org1.Admin, "permission=admin") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Admin should find folder with permission=admin") } // 10. Admin searching without permission parameter should find it (has Admin access) { res := callSearch(helper.Org1.Admin, "") found := false for _, h := range res.Hits { if h.Name == folderUID { // Verify it's our folder found = true break } } require.True(t, found, "Admin should find folder without permission parameter") } }) } // Types and helpers for permission setting type ResourcePermissionLevel int const ( ResourcePermissionLevelView ResourcePermissionLevel = 1 ResourcePermissionLevelEdit ResourcePermissionLevel = 2 ResourcePermissionLevelAdmin ResourcePermissionLevel = 4 ) type ResourcePermissionSetting struct { UserID *int64 `json:"userId,omitempty"` TeamID *int64 `json:"teamId,omitempty"` Role *string `json:"role,omitempty"` Level ResourcePermissionLevel `json:"permission"` } type permissionRequest struct { Items []ResourcePermissionSetting `json:"items"` } func setFolderPermissions(t *testing.T, helper *apis.K8sTestHelper, actingUser apis.User, folderUID string, permissions []ResourcePermissionSetting) { reqBody := permissionRequest{ Items: permissions, } jsonBody, err := json.Marshal(reqBody) require.NoError(t, err, "Failed to marshal permissions to JSON") path := fmt.Sprintf("/api/folders/%s/permissions", folderUID) resp := apis.DoRequest(helper, apis.RequestParams{ User: actingUser, Method: http.MethodPost, Path: path, Body: jsonBody, ContentType: "application/json", }, &struct{}{}) require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for folder %s", folderUID) } // roundTo rounds a float64 to a specified number of decimal places. func roundTo(n float64, decimals uint32) float64 { // Calculate the power of 10 for the desired number of decimals scale := math.Pow(10, float64(decimals)) // Multiply, round to the nearest integer, and then divide back return math.Round(n*scale) / scale }