From 0e4b701bd6d3a306214df14557158e45876a1156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Wed, 19 Nov 2025 13:05:07 +0100 Subject: [PATCH] fix(dashboards): apply permission search filter if provided (#114147) --- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 3 + pkg/registry/apis/dashboard/search.go | 19 ++ pkg/tests/apis/dashboard/search_test.go | 286 ++++++++++++++++++ .../dashboard.grafana.app-v0alpha1.json | 13 + public/app/features/search/service/unified.ts | 4 + 5 files changed, 325 insertions(+) create mode 100644 pkg/tests/apis/dashboard/search_test.go diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 26f8053d29c..264ac389e42 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -245,6 +245,7 @@ const injectedRtkApi = api facet: queryArg.facet, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, + permission: queryArg.permission, sort: queryArg.sort, limit: queryArg.limit, explain: queryArg.explain, @@ -611,6 +612,8 @@ export type GetSearchApiArg = { tags?: string[]; /** find dashboards that reference a given libraryPanel */ libraryPanel?: string; + /** permission needed for the resource (View, Edit, Admin) */ + permission?: 'View' | 'Edit' | 'Admin'; /** sortable field */ sort?: string; /** number of results to return */ diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index d471cba752a..ec2f6053b95 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -24,6 +24,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" 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" @@ -130,6 +131,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.StringProperty(), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "permission", + In: "query", + Description: "permission needed for the resource (View, Edit, Admin)", + Required: false, + Schema: spec.StringProperty().WithEnum("View", "Edit", "Admin"), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "sort", @@ -315,6 +325,15 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { } searchRequest.Fields = fields + switch strings.ToLower(queryParams.Get("permission")) { + case "edit": + searchRequest.Permission = int64(dashboardaccess.PERMISSION_EDIT) + case "view": + searchRequest.Permission = int64(dashboardaccess.PERMISSION_VIEW) + case "admin": + searchRequest.Permission = int64(dashboardaccess.PERMISSION_ADMIN) + } + // A search request can include multiple types, we need to acces the slice directly. types := queryParams["type"] hasDash := len(types) == 0 || slices.Contains(types, "dashboard") diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go new file mode 100644 index 00000000000..129a44aee21 --- /dev/null +++ b/pkg/tests/apis/dashboard/search_test.go @@ -0,0 +1,286 @@ +package dashboards + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "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/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util/testutil" +) + +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{ + 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) +} diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 42af6d54a98..06bad0ba004 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1820,6 +1820,19 @@ "type": "string" } }, + { + "name": "permission", + "in": "query", + "description": "permission needed for the resource (View, Edit, Admin)", + "schema": { + "type": "string", + "enum": [ + "View", + "Edit", + "Admin" + ] + } + }, { "name": "sort", "in": "query", diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index b352d61f038..4ab551997a3 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -318,6 +318,10 @@ export class UnifiedSearcher implements GrafanaSearcher { uri += '&' + query.uid.map((name) => `name=${encodeURIComponent(name)}`).join('&'); } + if (query.permission) { + uri += `&permission=${query.permission}`; + } + if (query.deleted) { uri = `${getAPIBaseURL('dashboard.grafana.app', 'v1beta1')}/dashboards/?labelSelector=grafana.app/get-trash=true`; }