RBAC: Allow plugins to use scoped actions (#90946)

Co-authored-by: gamab <gabriel.mabille@grafana.com>
pull/90970/head
Kevin Minehart 1 year ago committed by GitHub
parent 95000f9fc8
commit c326d865c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      pkg/api/pluginproxy/ds_proxy.go
  2. 56
      pkg/api/pluginproxy/ds_proxy_test.go
  3. 4
      pkg/api/pluginproxy/pluginproxy.go
  4. 23
      pkg/api/pluginproxy/pluginproxy_test.go
  5. 2
      pkg/middleware/auth.go
  6. 2
      pkg/services/navtree/navtreeimpl/applinks.go
  7. 195
      pkg/services/navtree/navtreeimpl/applinks_test.go
  8. 40
      pkg/services/pluginsintegration/pluginaccesscontrol/accesscontrol.go

@ -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 {

@ -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 = identity.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 = identity.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 = identity.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 = identity.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, zanzana.NewNoopClient()),
&actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))

@ -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)
}

@ -455,7 +455,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
},
}
@ -480,7 +486,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,
},
@ -490,6 +496,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 {
@ -534,6 +552,7 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) {
},
}
ps := &pluginsettings.DTO{
PluginID: "test-app",
SecureJSONData: map[string][]byte{},
}
cfg := &setting.Cfg{}

@ -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

@ -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)

@ -407,20 +407,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: identity.RoleEditor,
Action: catalogReadAction,
Role: identity.RoleViewer,
},
{
Name: "Page2",
Path: "/a/test-app1/page2",
Name: "Catalog",
Path: "/a/test-app1/catalog",
Type: "page",
AddToNav: true,
Role: identity.RoleEditor,
Action: catalogReadAction,
},
{
Name: "Announcements",
Path: "/a/test-app1/announcements",
Type: "page",
AddToNav: true,
Role: identity.RoleViewer,
Action: pluginaccesscontrol.ActionAppAccess,
},
},
},
@ -443,77 +451,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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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 = identity.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)
})
})
}

@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
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"
@ -88,3 +89,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)
}

Loading…
Cancel
Save