Medium severity5.4NVD Advisory· Published Aug 20, 2024· Updated Apr 15, 2026
CVE-2024-6322
CVE-2024-6322
Description
Access control for plugin data sources protected by the ReqActions json field of the plugin.json is bypassed if the user or service account is granted associated access to any other data source, as the ReqActions check was not scoped to each specific datasource. The account must have prior query access to the impacted datasource.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/grafana/grafanaGo | >= 11.1.0, < 11.1.1 | 11.1.1 |
github.com/grafana/grafanaGo | >= 11.1.2, < 11.1.3 | 11.1.3 |
github.com/grafana/grafanaGo | >= 0.0.0-20240521130516-0072e4a92d89, < 0.0.0-20240725142242-c326d865c58b | 0.0.0-20240725142242-c326d865c58b |
github.com/grafana/grafanaGo | >= 1.9.2-0.20240521130516-0072e4a92d89, < 1.9.2-0.20240725142242-c326d865c58b | 1.9.2-0.20240725142242-c326d865c58b |
Patches
24cb3ba5d1a7aRBAC: Allow plugins to use scoped actions (#90945)
8 files changed · +246 −86
pkg/api/pluginproxy/ds_proxy.go+5 −5 modified@@ -19,11 +19,11 @@ import ( glog "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" + pluginac "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/proxyutil" @@ -341,12 +341,12 @@ func (proxy *DataSourceProxy) hasAccessToRoute(route *plugins.Route) bool { ctxLogger := logger.FromContext(proxy.ctx.Req.Context()) useRBAC := proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagAccessControlOnCall) && route.ReqAction != "" if useRBAC { - routeEval := accesscontrol.EvalPermission(route.ReqAction) - ok := routeEval.Evaluate(proxy.ctx.GetPermissions()) - if !ok { + routeEval := pluginac.GetDataSourceRouteEvaluator(proxy.ds.UID, route.ReqAction) + hasAccess := routeEval.Evaluate(proxy.ctx.GetPermissions()) + if !hasAccess { ctxLogger.Debug("plugin route is covered by RBAC, user doesn't have access", "route", proxy.ctx.Req.URL.Path, "action", route.ReqAction, "path", route.Path, "method", route.Method) } - return ok + return hasAccess } if route.ReqRole.IsValid() { if hasUserRole := proxy.ctx.HasUserRole(route.ReqRole); !hasUserRole {
pkg/api/pluginproxy/ds_proxy_test.go+55 −1 modified@@ -108,9 +108,18 @@ func TestDataSourceProxy_routeRule(t *testing.T) { Path: "mypath", URL: "https://example.com/api/v1/", }, + { + Path: "api/rbac-home", + ReqAction: "datasources:read", + }, + { + Path: "api/rbac-restricted", + ReqAction: "test-app.settings:read", + }, } ds := &datasources.DataSource{ + UID: "dsUID", JsonData: simplejson.NewFromAny(map[string]any{ "clientId": "asd", "dynamicUrl": "https://dynamic.grafana.com", @@ -249,6 +258,51 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) }) }) + + t.Run("plugin route with RBAC protection user is allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"test-app.settings:read": nil}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-restricted") + require.NoError(t, err) + err = proxy.validateRequest() + require.NoError(t, err) + }) + + t.Run("plugin route with RBAC protection user is not allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"test-app:read": nil}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-restricted") + require.NoError(t, err) + err = proxy.validateRequest() + require.Error(t, err) + }) + + t.Run("plugin route with dynamic RBAC protection user is allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"datasources:read": {"datasources:uid:dsUID"}}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-home") + require.NoError(t, err) + err = proxy.validateRequest() + require.NoError(t, err) + }) + + t.Run("plugin route with dynamic RBAC protection user is not allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + // Has access but to another app + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"datasources:read": {"datasources:uid:notTheDsUID"}}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-home") + require.NoError(t, err) + err = proxy.validateRequest() + require.Error(t, err) + }) }) t.Run("Plugin with multiple routes for token auth", func(t *testing.T) { @@ -1021,7 +1075,7 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource cfg := setting.NewCfg() secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger()) - features := featuremgmt.WithFeatures() + features := featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
pkg/api/pluginproxy/pluginproxy.go+3 −1 modified@@ -15,6 +15,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" + pluginac "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" @@ -130,7 +131,8 @@ func (proxy *PluginProxy) HandleRequest() { func (proxy *PluginProxy) hasAccessToRoute(route *plugins.Route) bool { useRBAC := proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagAccessControlOnCall) && route.ReqAction != "" if useRBAC { - hasAccess := ac.HasAccess(proxy.accessControl, proxy.ctx)(ac.EvalPermission(route.ReqAction)) + routeEval := pluginac.GetPluginRouteEvaluator(proxy.ps.PluginID, route.ReqAction) + hasAccess := ac.HasAccess(proxy.accessControl, proxy.ctx)(routeEval) if !hasAccess { proxy.ctx.Logger.Debug("plugin route is covered by RBAC, user doesn't have access", "route", proxy.ctx.Req.URL.Path) }
pkg/api/pluginproxy/pluginproxy_test.go+21 −2 modified@@ -454,7 +454,13 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { Path: "projects", Method: "GET", URL: "http://localhost/api/projects", - ReqAction: "plugin-id.projects:read", // Protected by RBAC action + ReqAction: "test-app.projects:read", // Protected by RBAC action + }, + { + Path: "home", + Method: "GET", + URL: "http://localhost/api/home", + ReqAction: "plugins.app:access", // Protected by RBAC action with plugin scope }, } @@ -479,7 +485,7 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { }, { proxyPath: "/projects", - usrPerms: map[string][]string{"plugin-id.projects:read": {}}, + usrPerms: map[string][]string{"test-app.projects:read": {}}, expectedURLPath: "/api/projects", expectedStatus: http.StatusOK, }, @@ -489,6 +495,18 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { expectedURLPath: "/api/projects", expectedStatus: http.StatusForbidden, }, + { + proxyPath: "/home", + usrPerms: map[string][]string{"plugins.app:access": {"plugins:id:not-the-test-app"}}, + expectedURLPath: "/api/home", + expectedStatus: http.StatusForbidden, + }, + { + proxyPath: "/home", + usrPerms: map[string][]string{"plugins.app:access": {"plugins:id:test-app"}}, + expectedURLPath: "/api/home", + expectedStatus: http.StatusOK, + }, } for _, tc := range tcs { @@ -533,6 +551,7 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { }, } ps := &pluginsettings.DTO{ + PluginID: "test-app", SecureJSONData: map[string][]byte{}, } cfg := &setting.Cfg{}
pkg/middleware/auth.go+1 −1 modified@@ -127,7 +127,7 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, fea if normalizeIncludePath(u.Path) == path { useRBAC := features.IsEnabledGlobally(featuremgmt.FlagAccessControlOnCall) && i.RequiresRBACAction() - if useRBAC && !hasAccess(ac.EvalPermission(i.Action)) { + if useRBAC && !hasAccess(pluginaccesscontrol.GetPluginRouteEvaluator(pluginID, i.Action)) { logger.Debug("Plugin include is covered by RBAC, user doesn't have access", "plugin", pluginID, "include", i.Name) permitted = false break
pkg/services/navtree/navtreeimpl/applinks.go+1 −1 modified@@ -269,7 +269,7 @@ func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID st hasAccess := ac.HasAccess(s.accessControl, c) return func(include *plugins.Includes) bool { useRBAC := s.features.IsEnabledGlobally(featuremgmt.FlagAccessControlOnCall) && include.RequiresRBACAction() - if useRBAC && !hasAccess(ac.EvalPermission(include.Action)) { + if useRBAC && !hasAccess(pluginaccesscontrol.GetPluginRouteEvaluator(pluginID, include.Action)) { s.log.Debug("plugin include is covered by RBAC, user doesn't have access", "plugin", pluginID, "include", include.Name)
pkg/services/navtree/navtreeimpl/applinks_test.go+120 −75 modified@@ -406,20 +406,28 @@ func TestAddAppLinksAccessControl(t *testing.T) { ID: "test-app1", Name: "Test app1 name", Type: plugins.TypeApp, Includes: []*plugins.Includes{ { - Name: "Catalog", - Path: "/a/test-app1/catalog", + Name: "Home", + Path: "/a/test-app1/home", Type: "page", AddToNav: true, DefaultNav: true, - Role: roletype.RoleEditor, - Action: catalogReadAction, + Role: roletype.RoleViewer, }, { - Name: "Page2", - Path: "/a/test-app1/page2", + Name: "Catalog", + Path: "/a/test-app1/catalog", + Type: "page", + AddToNav: true, + Role: roletype.RoleEditor, + Action: catalogReadAction, + }, + { + Name: "Announcements", + Path: "/a/test-app1/announcements", Type: "page", AddToNav: true, Role: roletype.RoleViewer, + Action: pluginaccesscontrol.ActionAppAccess, }, }, }, @@ -442,77 +450,114 @@ func TestAddAppLinksAccessControl(t *testing.T) { }, } - t.Run("Should not add app links when the user cannot access app plugins", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{} - user.OrgRole = roletype.RoleAdmin - - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - require.Len(t, treeRoot.Children, 0) - }) - t.Run("Should add both includes when the user is an editor", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, - } - user.OrgRole = roletype.RoleEditor - - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) - }) - t.Run("Should add one include when the user is a viewer", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, - } - user.OrgRole = roletype.RoleViewer - - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) - }) - t.Run("Should add both includes when the user is a viewer with catalog read", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}, catalogReadAction: []string{}}, - } - user.OrgRole = roletype.RoleViewer - service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + t.Run("Without plugin RBAC - Enforce role", func(t *testing.T) { + t.Run("Should not add app links when the user cannot access app plugins", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{} + user.OrgRole = roletype.RoleAdmin - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 0) + }) + t.Run(" Should add all includes when the user is an editor", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleEditor + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 2) + require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Children[0].Url) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[1].Url) + }) + t.Run("Should add two includes when the user is a viewer", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleViewer + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[0].Url) + }) }) - t.Run("Should add one include when the user is an editor without catalog read", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, - } - user.OrgRole = roletype.RoleEditor - service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) + t.Run("With plugin RBAC - Enforce action first", func(t *testing.T) { + t.Run("Should not see any includes with no app access", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"plugins:id:not-the-test-app1"}}, + } + user.OrgRole = roletype.RoleNone + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 0) + }) + t.Run("Should only see the announcements as a none role user with app access", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"plugins:id:test-app1"}}, + } + user.OrgRole = roletype.RoleNone + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Len(t, appsNode.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[0].Url) + }) + t.Run("Should now see the catalog as a viewer with catalog read", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"plugins:id:test-app1"}, catalogReadAction: []string{}}, + } + user.OrgRole = roletype.RoleViewer + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 2) + require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Children[0].Url) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[1].Url) + }) + t.Run("Should not see the catalog include as an editor without catalog read", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleEditor + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[0].Url) + }) }) }
pkg/services/pluginsintegration/pluginaccesscontrol/accesscontrol.go+40 −0 modified@@ -3,6 +3,7 @@ package pluginaccesscontrol import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" @@ -75,3 +76,42 @@ func DeclareRBACRoles(service ac.Service, cfg *setting.Cfg, features featuremgmt return service.DeclareFixedRoles(AppPluginsReader, PluginsWriter, PluginsMaintainer) } + +var datasourcesActions = map[string]bool{ + datasources.ActionIDRead: true, + datasources.ActionQuery: true, + datasources.ActionRead: true, + datasources.ActionWrite: true, + datasources.ActionDelete: true, + datasources.ActionPermissionsRead: true, + datasources.ActionPermissionsWrite: true, + "datasources.caching:read": true, + "datasources.caching:write": true, + ac.ActionAlertingRuleExternalRead: true, + ac.ActionAlertingRuleExternalWrite: true, + ac.ActionAlertingInstancesExternalRead: true, + ac.ActionAlertingInstancesExternalWrite: true, + ac.ActionAlertingNotificationsExternalRead: true, + ac.ActionAlertingNotificationsExternalWrite: true, +} + +// GetDataSourceRouteEvaluator returns an evaluator for the given data source UID and action. +func GetDataSourceRouteEvaluator(dsUID, action string) ac.Evaluator { + if datasourcesActions[action] { + return ac.EvalPermission(action, "datasources:uid:"+dsUID) + } + return ac.EvalPermission(action) +} + +var pluginsActions = map[string]bool{ + ActionWrite: true, + ActionAppAccess: true, +} + +// GetPluginRouteEvaluator returns an evaluator for the given plugin ID and action. +func GetPluginRouteEvaluator(pluginID, action string) ac.Evaluator { + if pluginsActions[action] { + return ac.EvalPermission(action, "plugins:id:"+pluginID) + } + return ac.EvalPermission(action) +}
9cdba084a910RBAC: Allow plugins to use scoped actions (#90945)
8 files changed · +246 −86
pkg/api/pluginproxy/ds_proxy.go+5 −5 modified@@ -19,11 +19,11 @@ import ( glog "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken" + pluginac "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/proxyutil" @@ -341,12 +341,12 @@ func (proxy *DataSourceProxy) hasAccessToRoute(route *plugins.Route) bool { ctxLogger := logger.FromContext(proxy.ctx.Req.Context()) useRBAC := proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagAccessControlOnCall) && route.ReqAction != "" if useRBAC { - routeEval := accesscontrol.EvalPermission(route.ReqAction) - ok := routeEval.Evaluate(proxy.ctx.GetPermissions()) - if !ok { + routeEval := pluginac.GetDataSourceRouteEvaluator(proxy.ds.UID, route.ReqAction) + hasAccess := routeEval.Evaluate(proxy.ctx.GetPermissions()) + if !hasAccess { ctxLogger.Debug("plugin route is covered by RBAC, user doesn't have access", "route", proxy.ctx.Req.URL.Path, "action", route.ReqAction, "path", route.Path, "method", route.Method) } - return ok + return hasAccess } if route.ReqRole.IsValid() { if hasUserRole := proxy.ctx.HasUserRole(route.ReqRole); !hasUserRole {
pkg/api/pluginproxy/ds_proxy_test.go+55 −1 modified@@ -108,9 +108,18 @@ func TestDataSourceProxy_routeRule(t *testing.T) { Path: "mypath", URL: "https://example.com/api/v1/", }, + { + Path: "api/rbac-home", + ReqAction: "datasources:read", + }, + { + Path: "api/rbac-restricted", + ReqAction: "test-app.settings:read", + }, } ds := &datasources.DataSource{ + UID: "dsUID", JsonData: simplejson.NewFromAny(map[string]any{ "clientId": "asd", "dynamicUrl": "https://dynamic.grafana.com", @@ -249,6 +258,51 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) }) }) + + t.Run("plugin route with RBAC protection user is allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"test-app.settings:read": nil}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-restricted") + require.NoError(t, err) + err = proxy.validateRequest() + require.NoError(t, err) + }) + + t.Run("plugin route with RBAC protection user is not allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"test-app:read": nil}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-restricted") + require.NoError(t, err) + err = proxy.validateRequest() + require.Error(t, err) + }) + + t.Run("plugin route with dynamic RBAC protection user is allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"datasources:read": {"datasources:uid:dsUID"}}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-home") + require.NoError(t, err) + err = proxy.validateRequest() + require.NoError(t, err) + }) + + t.Run("plugin route with dynamic RBAC protection user is not allowed", func(t *testing.T) { + ctx, _ := setUp() + ctx.SignedInUser.OrgID = int64(1) + ctx.SignedInUser.OrgRole = org.RoleNone + // Has access but to another app + ctx.SignedInUser.Permissions = map[int64]map[string][]string{1: {"datasources:read": {"datasources:uid:notTheDsUID"}}} + proxy, err := setupDSProxyTest(t, ctx, ds, routes, "api/rbac-home") + require.NoError(t, err) + err = proxy.validateRequest() + require.Error(t, err) + }) }) t.Run("Plugin with multiple routes for token auth", func(t *testing.T) { @@ -1021,7 +1075,7 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource cfg := setting.NewCfg() secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger()) - features := featuremgmt.WithFeatures() + features := featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
pkg/api/pluginproxy/pluginproxy.go+3 −1 modified@@ -15,6 +15,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" + pluginac "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" @@ -130,7 +131,8 @@ func (proxy *PluginProxy) HandleRequest() { func (proxy *PluginProxy) hasAccessToRoute(route *plugins.Route) bool { useRBAC := proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagAccessControlOnCall) && route.ReqAction != "" if useRBAC { - hasAccess := ac.HasAccess(proxy.accessControl, proxy.ctx)(ac.EvalPermission(route.ReqAction)) + routeEval := pluginac.GetPluginRouteEvaluator(proxy.ps.PluginID, route.ReqAction) + hasAccess := ac.HasAccess(proxy.accessControl, proxy.ctx)(routeEval) if !hasAccess { proxy.ctx.Logger.Debug("plugin route is covered by RBAC, user doesn't have access", "route", proxy.ctx.Req.URL.Path) }
pkg/api/pluginproxy/pluginproxy_test.go+21 −2 modified@@ -454,7 +454,13 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { Path: "projects", Method: "GET", URL: "http://localhost/api/projects", - ReqAction: "plugin-id.projects:read", // Protected by RBAC action + ReqAction: "test-app.projects:read", // Protected by RBAC action + }, + { + Path: "home", + Method: "GET", + URL: "http://localhost/api/home", + ReqAction: "plugins.app:access", // Protected by RBAC action with plugin scope }, } @@ -479,7 +485,7 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { }, { proxyPath: "/projects", - usrPerms: map[string][]string{"plugin-id.projects:read": {}}, + usrPerms: map[string][]string{"test-app.projects:read": {}}, expectedURLPath: "/api/projects", expectedStatus: http.StatusOK, }, @@ -489,6 +495,18 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { expectedURLPath: "/api/projects", expectedStatus: http.StatusForbidden, }, + { + proxyPath: "/home", + usrPerms: map[string][]string{"plugins.app:access": {"plugins:id:not-the-test-app"}}, + expectedURLPath: "/api/home", + expectedStatus: http.StatusForbidden, + }, + { + proxyPath: "/home", + usrPerms: map[string][]string{"plugins.app:access": {"plugins:id:test-app"}}, + expectedURLPath: "/api/home", + expectedStatus: http.StatusOK, + }, } for _, tc := range tcs { @@ -533,6 +551,7 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) { }, } ps := &pluginsettings.DTO{ + PluginID: "test-app", SecureJSONData: map[string][]byte{}, } cfg := &setting.Cfg{}
pkg/middleware/auth.go+1 −1 modified@@ -127,7 +127,7 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, fea if normalizeIncludePath(u.Path) == path { useRBAC := features.IsEnabledGlobally(featuremgmt.FlagAccessControlOnCall) && i.RequiresRBACAction() - if useRBAC && !hasAccess(ac.EvalPermission(i.Action)) { + if useRBAC && !hasAccess(pluginaccesscontrol.GetPluginRouteEvaluator(pluginID, i.Action)) { logger.Debug("Plugin include is covered by RBAC, user doesn't have access", "plugin", pluginID, "include", i.Name) permitted = false break
pkg/services/navtree/navtreeimpl/applinks.go+1 −1 modified@@ -269,7 +269,7 @@ func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID st hasAccess := ac.HasAccess(s.accessControl, c) return func(include *plugins.Includes) bool { useRBAC := s.features.IsEnabledGlobally(featuremgmt.FlagAccessControlOnCall) && include.RequiresRBACAction() - if useRBAC && !hasAccess(ac.EvalPermission(include.Action)) { + if useRBAC && !hasAccess(pluginaccesscontrol.GetPluginRouteEvaluator(pluginID, include.Action)) { s.log.Debug("plugin include is covered by RBAC, user doesn't have access", "plugin", pluginID, "include", include.Name)
pkg/services/navtree/navtreeimpl/applinks_test.go+120 −75 modified@@ -406,20 +406,28 @@ func TestAddAppLinksAccessControl(t *testing.T) { ID: "test-app1", Name: "Test app1 name", Type: plugins.TypeApp, Includes: []*plugins.Includes{ { - Name: "Catalog", - Path: "/a/test-app1/catalog", + Name: "Home", + Path: "/a/test-app1/home", Type: "page", AddToNav: true, DefaultNav: true, - Role: roletype.RoleEditor, - Action: catalogReadAction, + Role: roletype.RoleViewer, }, { - Name: "Page2", - Path: "/a/test-app1/page2", + Name: "Catalog", + Path: "/a/test-app1/catalog", + Type: "page", + AddToNav: true, + Role: roletype.RoleEditor, + Action: catalogReadAction, + }, + { + Name: "Announcements", + Path: "/a/test-app1/announcements", Type: "page", AddToNav: true, Role: roletype.RoleViewer, + Action: pluginaccesscontrol.ActionAppAccess, }, }, }, @@ -442,77 +450,114 @@ func TestAddAppLinksAccessControl(t *testing.T) { }, } - t.Run("Should not add app links when the user cannot access app plugins", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{} - user.OrgRole = roletype.RoleAdmin - - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - require.Len(t, treeRoot.Children, 0) - }) - t.Run("Should add both includes when the user is an editor", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, - } - user.OrgRole = roletype.RoleEditor - - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) - }) - t.Run("Should add one include when the user is a viewer", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, - } - user.OrgRole = roletype.RoleViewer - - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) - }) - t.Run("Should add both includes when the user is a viewer with catalog read", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}, catalogReadAction: []string{}}, - } - user.OrgRole = roletype.RoleViewer - service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + t.Run("Without plugin RBAC - Enforce role", func(t *testing.T) { + t.Run("Should not add app links when the user cannot access app plugins", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{} + user.OrgRole = roletype.RoleAdmin - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Url) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 0) + }) + t.Run(" Should add all includes when the user is an editor", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleEditor + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 2) + require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Children[0].Url) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[1].Url) + }) + t.Run("Should add two includes when the user is a viewer", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleViewer + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[0].Url) + }) }) - t.Run("Should add one include when the user is an editor without catalog read", func(t *testing.T) { - treeRoot := navtree.NavTreeRoot{} - user.Permissions = map[int64]map[string][]string{ - 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, - } - user.OrgRole = roletype.RoleEditor - service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) - err := service.addAppLinks(&treeRoot, reqCtx) - require.NoError(t, err) - appsNode := treeRoot.FindById(navtree.NavIDApps) - require.Len(t, appsNode.Children, 1) - require.Equal(t, "Test app1 name", appsNode.Children[0].Text) - require.Len(t, appsNode.Children[0].Children, 1) - require.Equal(t, "/a/test-app1/page2", appsNode.Children[0].Children[0].Url) + t.Run("With plugin RBAC - Enforce action first", func(t *testing.T) { + t.Run("Should not see any includes with no app access", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"plugins:id:not-the-test-app1"}}, + } + user.OrgRole = roletype.RoleNone + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + require.Len(t, treeRoot.Children, 0) + }) + t.Run("Should only see the announcements as a none role user with app access", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"plugins:id:test-app1"}}, + } + user.OrgRole = roletype.RoleNone + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Len(t, appsNode.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[0].Url) + }) + t.Run("Should now see the catalog as a viewer with catalog read", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"plugins:id:test-app1"}, catalogReadAction: []string{}}, + } + user.OrgRole = roletype.RoleViewer + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 2) + require.Equal(t, "/a/test-app1/catalog", appsNode.Children[0].Children[0].Url) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[1].Url) + }) + t.Run("Should not see the catalog include as an editor without catalog read", func(t *testing.T) { + treeRoot := navtree.NavTreeRoot{} + user.Permissions = map[int64]map[string][]string{ + 1: {pluginaccesscontrol.ActionAppAccess: []string{"*"}}, + } + user.OrgRole = roletype.RoleEditor + service.features = featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall) + + err := service.addAppLinks(&treeRoot, reqCtx) + require.NoError(t, err) + appsNode := treeRoot.FindById(navtree.NavIDApps) + require.Len(t, appsNode.Children, 1) + require.Equal(t, "Test app1 name", appsNode.Children[0].Text) + require.Equal(t, "/a/test-app1/home", appsNode.Children[0].Url) + require.Len(t, appsNode.Children[0].Children, 1) + require.Equal(t, "/a/test-app1/announcements", appsNode.Children[0].Children[0].Url) + }) }) }
pkg/services/pluginsintegration/pluginaccesscontrol/accesscontrol.go+40 −0 modified@@ -3,6 +3,7 @@ package pluginaccesscontrol import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" @@ -75,3 +76,42 @@ func DeclareRBACRoles(service ac.Service, cfg *setting.Cfg, features featuremgmt return service.DeclareFixedRoles(AppPluginsReader, PluginsWriter, PluginsMaintainer) } + +var datasourcesActions = map[string]bool{ + datasources.ActionIDRead: true, + datasources.ActionQuery: true, + datasources.ActionRead: true, + datasources.ActionWrite: true, + datasources.ActionDelete: true, + datasources.ActionPermissionsRead: true, + datasources.ActionPermissionsWrite: true, + "datasources.caching:read": true, + "datasources.caching:write": true, + ac.ActionAlertingRuleExternalRead: true, + ac.ActionAlertingRuleExternalWrite: true, + ac.ActionAlertingInstancesExternalRead: true, + ac.ActionAlertingInstancesExternalWrite: true, + ac.ActionAlertingNotificationsExternalRead: true, + ac.ActionAlertingNotificationsExternalWrite: true, +} + +// GetDataSourceRouteEvaluator returns an evaluator for the given data source UID and action. +func GetDataSourceRouteEvaluator(dsUID, action string) ac.Evaluator { + if datasourcesActions[action] { + return ac.EvalPermission(action, "datasources:uid:"+dsUID) + } + return ac.EvalPermission(action) +} + +var pluginsActions = map[string]bool{ + ActionWrite: true, + ActionAppAccess: true, +} + +// GetPluginRouteEvaluator returns an evaluator for the given plugin ID and action. +func GetPluginRouteEvaluator(pluginID, action string) ac.Evaluator { + if pluginsActions[action] { + return ac.EvalPermission(action, "plugins:id:"+pluginID) + } + return ac.EvalPermission(action) +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-hh8p-374f-qgr5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-6322ghsaADVISORY
- github.com/grafana/grafana/commit/4cb3ba5d1a7ab8b9676034e89dada2fcde1766efghsaWEB
- github.com/grafana/grafana/commit/9cdba084a9100c6b11d32eef9d2bd53656c6964aghsaWEB
- grafana.com/security/security-advisories/cve-2024-6322ghsaWEB
- grafana.com/security/security-advisories/cve-2024-6322/nvd
News mentions
0No linked articles in our index yet.