RBAC: Add permissions to install and configure plugins (#51829)

* RBAC: Allow app plugins restriction

Co-authored-by: Kalle Persson <kalle.persson@grafana.com>

* Moving declaration to HttpServer

Co-Authored-By: marefr <marcus.efraimsson@gmail.com>

* Picking changes from the other branch

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>

* Rename plugins.settings to plugins

Co-authored-by: Kalle Persson <kalle.persson@grafana.com>

* Account for PluginAdminExternalManageEnabled

Co-authored-by: Will Browne <will.browne@grafana.com>

* Set metadata on instantiation

Co-authored-by: Jguer <joao.guerreiro@grafana.com>

Co-authored-by: Kalle Persson <kalle.persson@grafana.com>
Co-authored-by: marefr <marcus.efraimsson@gmail.com>
Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
Co-authored-by: Will Browne <will.browne@grafana.com>
Co-authored-by: Jguer <joao.guerreiro@grafana.com>
This commit is contained in:
Gabriel MABILLE 2022-09-09 09:44:50 +02:00 committed by GitHub
parent 8c081d4523
commit 101349fe49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 397 additions and 53 deletions

View File

@ -4900,9 +4900,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/plugins/admin/pages/PluginDetails.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/plugins/admin/pages/PluginDetails.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -43,7 +43,7 @@ var (
// that HTTPServer needs
func (hs *HTTPServer) declareFixedRoles() error {
// Declare plugins roles
if err := plugins.DeclareRBACRoles(hs.accesscontrolService); err != nil {
if err := plugins.DeclareRBACRoles(hs.accesscontrolService, hs.Cfg); err != nil {
return err
}

View File

@ -369,16 +369,16 @@ func (hs *HTTPServer) registerRoutes() {
if hs.Cfg.PluginAdminEnabled && !hs.Cfg.PluginAdminExternalManageEnabled {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Post("/:pluginId/install", routing.Wrap(hs.InstallPlugin))
pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin))
}, reqGrafanaAdmin)
pluginRoute.Post("/:pluginId/install", authorize(reqGrafanaAdmin, ac.EvalPermission(plugins.ActionInstall)), routing.Wrap(hs.InstallPlugin))
pluginRoute.Post("/:pluginId/uninstall", authorize(reqGrafanaAdmin, ac.EvalPermission(plugins.ActionInstall)), routing.Wrap(hs.UninstallPlugin))
})
}
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Get("/:pluginId/dashboards/", routing.Wrap(hs.GetPluginDashboards))
pluginRoute.Post("/:pluginId/settings", routing.Wrap(hs.UpdatePluginSetting))
pluginRoute.Get("/:pluginId/metrics", routing.Wrap(hs.CollectPluginMetrics))
}, reqOrgAdmin)
pluginRoute.Get("/:pluginId/dashboards/", reqOrgAdmin, routing.Wrap(hs.GetPluginDashboards))
pluginRoute.Post("/:pluginId/settings", authorize(reqOrgAdmin, ac.EvalPermission(plugins.ActionWrite, pluginIDScope)), routing.Wrap(hs.UpdatePluginSetting))
pluginRoute.Get("/:pluginId/metrics", reqOrgAdmin, routing.Wrap(hs.CollectPluginMetrics))
})
apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
apiRoute.Any("/datasources/proxy/:id/*", authorize(reqSignedIn, ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest)

View File

@ -2,6 +2,7 @@ package dtos
import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
type PluginSetting struct {
@ -42,6 +43,7 @@ type PluginListItem struct {
Signature plugins.SignatureStatus `json:"signature"`
SignatureType plugins.SignatureType `json:"signatureType"`
SignatureOrg string `json:"signatureOrg"`
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
}
type PluginList []PluginListItem

View File

@ -2,8 +2,11 @@ package api
import (
"context"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/pluginsettings"
)
type fakePluginManager struct {
@ -44,6 +47,9 @@ func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.Pl
func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO
if len(pluginTypes) == 0 {
pluginTypes = plugins.PluginTypes
}
for _, v := range pr.plugins {
for _, t := range pluginTypes {
if v.Type == t {
@ -72,3 +78,66 @@ type fakePluginStaticRouteResolver struct {
func (psrr *fakePluginStaticRouteResolver) Routes() []*plugins.StaticRoute {
return psrr.routes
}
type fakePluginSettings struct {
pluginsettings.Service
plugins map[string]*pluginsettings.DTO
}
// GetPluginSettings returns all Plugin Settings for the provided Org
func (ps *fakePluginSettings) GetPluginSettings(ctx context.Context, args *pluginsettings.GetArgs) ([]*pluginsettings.DTO, error) {
res := []*pluginsettings.DTO{}
for _, dto := range ps.plugins {
res = append(res, dto)
}
return res, nil
}
// GetPluginSettingByPluginID returns a Plugin Settings by Plugin ID
func (ps *fakePluginSettings) GetPluginSettingByPluginID(ctx context.Context, args *pluginsettings.GetByPluginIDArgs) (*pluginsettings.DTO, error) {
if res, ok := ps.plugins[args.PluginID]; ok {
return res, nil
}
return nil, models.ErrPluginSettingNotFound
}
// UpdatePluginSetting updates a Plugin Setting
func (ps *fakePluginSettings) UpdatePluginSetting(ctx context.Context, args *pluginsettings.UpdateArgs) error {
var secureData map[string][]byte
if args.SecureJSONData != nil {
secureData := map[string][]byte{}
for k, v := range args.SecureJSONData {
secureData[k] = ([]byte)(v)
}
}
// save
ps.plugins[args.PluginID] = &pluginsettings.DTO{
ID: int64(len(ps.plugins)),
OrgID: args.OrgID,
PluginID: args.PluginID,
PluginVersion: args.PluginVersion,
JSONData: args.JSONData,
SecureJSONData: secureData,
Enabled: args.Enabled,
Pinned: args.Pinned,
Updated: time.Now(),
}
return nil
}
// UpdatePluginSettingPluginVersion updates a Plugin Setting's plugin version
func (ps *fakePluginSettings) UpdatePluginSettingPluginVersion(ctx context.Context, args *pluginsettings.UpdatePluginVersionArgs) error {
if res, ok := ps.plugins[args.PluginID]; ok {
res.PluginVersion = args.PluginVersion
return nil
}
return models.ErrPluginSettingNotFound
}
// DecryptedValues decrypts the encrypted secureJSONData of the provided plugin setting and
// returns the decrypted values.
func (ps *fakePluginSettings) DecryptedValues(dto *pluginsettings.DTO) map[string]string {
// TODO: Implement
return nil
}

View File

@ -65,7 +65,7 @@ import (
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/playlist"
"github.com/grafana/grafana/pkg/services/plugindashboards"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings"
pref "github.com/grafana/grafana/pkg/services/preference"
"github.com/grafana/grafana/pkg/services/provisioning"
publicdashboardsApi "github.com/grafana/grafana/pkg/services/publicdashboards/api"
@ -169,7 +169,7 @@ type HTTPServer struct {
commentsService *comments.Service
AlertNotificationService *alerting.AlertNotificationService
dashboardsnapshotsService dashboardsnapshots.Service
PluginSettings *pluginSettings.Service
PluginSettings pluginSettings.Service
AvatarCacheServer *avatar.AvatarCacheServer
preferenceService pref.Service
Csrf csrf.Service
@ -218,7 +218,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
dashboardsnapshotsService dashboardsnapshots.Service, commentsService *comments.Service, pluginSettings *pluginSettings.Service,
dashboardsnapshotsService dashboardsnapshots.Service, commentsService *comments.Service, pluginSettings pluginSettings.Service,
avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service,
teamsPermissionsService accesscontrol.TeamPermissionsService, folderPermissionsService accesscontrol.FolderPermissionsService,
dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service,

View File

@ -384,7 +384,8 @@ func (hs *HTTPServer) setupConfigNodes(c *models.ReqContext) ([]*dtos.NavLink, e
})
}
if c.OrgRole == org.RoleAdmin || (hs.Cfg.PluginAdminEnabled && ac.ReqGrafanaAdmin(c)) {
// FIXME: while we don't have a permissions for listing plugins the legacy check has to stay as a default
if plugins.ReqCanAdminPlugins(hs.Cfg)(c) || hasAccess(plugins.ReqCanAdminPlugins(hs.Cfg), plugins.AdminAccessEvaluator) {
configNodes = append(configNodes, &dtos.NavLink{
Text: "Plugins",
Id: "plugins",

View File

@ -13,10 +13,6 @@ import (
"sort"
"strings"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
@ -26,6 +22,9 @@ import (
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/plugins/storage"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
@ -35,22 +34,33 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
typeFilter := c.Query("type")
enabledFilter := c.Query("enabled")
embeddedFilter := c.Query("embedded")
// "" => no filter
// "0" => filter out core plugins
// "1" => filter out non-core plugins
coreFilter := c.Query("core")
// When using access control anyone that can create a data source should be able to list all data sources installed
// FIXME: while we don't have permissions for listing plugins we need this complex check:
// When using access control, should be able to list non-core plugins:
// * anyone that can create a data source
// * anyone that can install a plugin
// Fallback to only letting admins list non-core plugins
hasAccess := accesscontrol.HasAccess(hs.AccessControl, c)
if !hasAccess(accesscontrol.ReqOrgAdmin, accesscontrol.EvalPermission(datasources.ActionCreate)) && !c.HasRole(org.RoleAdmin) {
coreFilter = "1"
}
reqOrgAdmin := ac.ReqHasRole(org.RoleAdmin)
hasAccess := ac.HasAccess(hs.AccessControl, c)
canListNonCorePlugins := reqOrgAdmin(c) || hasAccess(reqOrgAdmin, ac.EvalAny(
ac.EvalPermission(datasources.ActionCreate),
ac.EvalPermission(plugins.ActionInstall),
))
pluginSettingsMap, err := hs.pluginSettings(c.Req.Context(), c.OrgID)
if err != nil {
return response.Error(500, "Failed to get list of plugins", err)
return response.Error(http.StatusInternalServerError, "Failed to get list of plugins", err)
}
result := make(dtos.PluginList, 0)
for _, pluginDef := range hs.pluginStore.Plugins(c.Req.Context()) {
// Filter plugins
pluginDefinitions := hs.pluginStore.Plugins(c.Req.Context())
filteredPluginDefinitions := []plugins.PluginDTO{}
filteredPluginIDs := map[string]bool{}
for _, pluginDef := range pluginDefinitions {
// filter out app sub plugins
if embeddedFilter == "0" && pluginDef.IncludedInAppID != "" {
continue
@ -61,6 +71,17 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
continue
}
// FIXME: while we don't have permissions for listing plugins we need this complex check:
// When using access control, should be able to list non-core plugins:
// * anyone that can create a data source
// * anyone that can install a plugin
// Should be able to list this installed plugin:
// * anyone that can edit its settings
if !pluginDef.IsCorePlugin() && !canListNonCorePlugins && !hasAccess(reqOrgAdmin,
ac.EvalPermission(plugins.ActionWrite, plugins.ScopeProvider.GetResourceScope(pluginDef.ID))) {
continue
}
// filter on type
if typeFilter != "" && typeFilter != string(pluginDef.Type) {
continue
@ -70,6 +91,29 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
continue
}
// filter out built in plugins
if pluginDef.BuiltIn {
continue
}
// filter out disabled plugins
if pluginSetting, exists := pluginSettingsMap[pluginDef.ID]; exists {
if enabledFilter == "1" && !pluginSetting.Enabled {
continue
}
}
filteredPluginDefinitions = append(filteredPluginDefinitions, pluginDef)
filteredPluginIDs[pluginDef.ID] = true
}
// Compute metadata
pluginsMetadata := hs.getMultiAccessControlMetadata(c, c.OrgID,
plugins.ScopeProvider.GetResourceScope(""), filteredPluginIDs)
// Prepare DTO
result := make(dtos.PluginList, 0)
for _, pluginDef := range filteredPluginDefinitions {
listItem := dtos.PluginListItem{
Id: pluginDef.ID,
Name: pluginDef.Name,
@ -82,6 +126,7 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
Signature: pluginDef.Signature,
SignatureType: pluginDef.SignatureType,
SignatureOrg: pluginDef.SignatureOrg,
AccessControl: pluginsMetadata[pluginDef.ID],
}
update, exists := hs.pluginsUpdateChecker.HasUpdate(c.Req.Context(), pluginDef.ID)
@ -99,16 +144,6 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
listItem.DefaultNavUrl = hs.Cfg.AppSubURL + "/plugins/" + listItem.Id + "/"
}
// filter out disabled plugins
if enabledFilter == "1" && !listItem.Enabled {
continue
}
// filter out built in plugins
if pluginDef.BuiltIn {
continue
}
result = append(result, listItem)
}
@ -127,9 +162,9 @@ func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Respon
// In a first iteration, we only have one permission for app plugins.
// We will need a different permission to allow users to configure the plugin without needing access to it.
if plugin.IsApp() {
hasAccess := accesscontrol.HasAccess(hs.AccessControl, c)
if !hasAccess(accesscontrol.ReqSignedIn,
accesscontrol.EvalPermission(plugins.ActionAppAccess, plugins.ScopeProvider.GetResourceScope(plugin.ID))) {
hasAccess := ac.HasAccess(hs.AccessControl, c)
if !hasAccess(ac.ReqSignedIn,
ac.EvalPermission(plugins.ActionAppAccess, plugins.ScopeProvider.GetResourceScope(plugin.ID))) {
return response.Error(http.StatusForbidden, "Access Denied", nil)
}
}

View File

@ -16,12 +16,16 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/log/logtest"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/updatechecker"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
@ -97,6 +101,56 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
}
}
func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) {
canInstall := []ac.Permission{{Action: plugins.ActionInstall}}
cannotInstall := []ac.Permission{{Action: "plugins:cannotinstall"}}
type testCase struct {
expectedCode int
permissions []ac.Permission
pluginAdminEnabled bool
pluginAdminExternalManageEnabled bool
}
tcs := []testCase{
{expectedCode: http.StatusNotFound, permissions: canInstall, pluginAdminEnabled: true, pluginAdminExternalManageEnabled: true},
{expectedCode: http.StatusNotFound, permissions: canInstall, pluginAdminEnabled: false, pluginAdminExternalManageEnabled: true},
{expectedCode: http.StatusNotFound, permissions: canInstall, pluginAdminEnabled: false, pluginAdminExternalManageEnabled: false},
{expectedCode: http.StatusForbidden, permissions: cannotInstall, pluginAdminEnabled: true, pluginAdminExternalManageEnabled: false},
{expectedCode: http.StatusOK, permissions: canInstall, pluginAdminEnabled: true, pluginAdminExternalManageEnabled: false},
}
testName := func(action string, tc testCase) string {
return fmt.Sprintf("%s request returns %d when adminEnabled: %t, externalEnabled: %t, permissions: %q",
action, tc.expectedCode, tc.pluginAdminEnabled, tc.pluginAdminExternalManageEnabled, tc.permissions)
}
pm := &fakePluginManager{
plugins: make(map[string]fakePlugin),
}
for _, tc := range tcs {
sc := setupHTTPServerWithCfg(t, true, &setting.Cfg{
RBACEnabled: true,
PluginAdminEnabled: tc.pluginAdminEnabled,
PluginAdminExternalManageEnabled: tc.pluginAdminExternalManageEnabled})
setInitCtxSignedInViewer(sc.initCtx)
setAccessControlPermissions(sc.acmock, tc.permissions, sc.initCtx.OrgID)
sc.hs.pluginManager = pm
t.Run(testName("Install", tc), func(t *testing.T) {
input := strings.NewReader("{ \"version\": \"1.0.2\" }")
response := callAPI(sc.server, http.MethodPost, "/api/plugins/test/install", input, t)
assert.Equal(t, tc.expectedCode, response.Code)
})
t.Run(testName("Uninstall", tc), func(t *testing.T) {
input := strings.NewReader("{ }")
response := callAPI(sc.server, http.MethodPost, "/api/plugins/test/uninstall", input, t)
assert.Equal(t, tc.expectedCode, response.Code)
})
}
}
func Test_GetPluginAssets(t *testing.T) {
pluginID := "test-plugin"
pluginDir := "."
@ -342,3 +396,100 @@ func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryData
return backend.NewQueryDataResponse(), nil
}
func Test_PluginsList_AccessControl(t *testing.T) {
pluginStore := fakePluginStore{plugins: map[string]plugins.PluginDTO{
"test-app": {
PluginDir: "/grafana/plugins/test-app/dist",
Class: "external",
DefaultNavURL: "/plugins/test-app/page/test",
Pinned: false,
Signature: "unsigned",
Module: "plugins/test-app/module",
BaseURL: "public/plugins/test-app",
JSONData: plugins.JSONData{
ID: "test-app",
Type: "app",
Name: "test-app",
Info: plugins.Info{
Version: "1.0.0",
},
},
},
"mysql": {
PluginDir: "/grafana/public/app/plugins/datasource/mysql",
Class: "core",
Pinned: false,
Signature: "internal",
Module: "app/plugins/datasource/mysql/module",
BaseURL: "public/app/plugins/datasource/mysql",
JSONData: plugins.JSONData{
ID: "mysql",
Type: "datasource",
Name: "MySQL",
Info: plugins.Info{
Author: plugins.InfoLink{Name: "Grafana Labs", URL: "https://grafana.com"},
Description: "Data source for MySQL databases",
},
},
},
}}
pluginSettings := fakePluginSettings{plugins: map[string]*pluginsettings.DTO{
"test-app": {ID: 0, OrgID: 1, PluginID: "test-app", PluginVersion: "1.0.0", Enabled: true},
"mysql": {ID: 0, OrgID: 1, PluginID: "mysql", PluginVersion: "", Enabled: true}},
}
type testCase struct {
expectedCode int
role org.RoleType
isGrafanaAdmin bool
expectedPlugins []string
filters map[string]string
}
tcs := []testCase{
{expectedCode: http.StatusOK, role: org.RoleViewer, expectedPlugins: []string{"mysql"}},
{expectedCode: http.StatusOK, role: org.RoleViewer, isGrafanaAdmin: true, expectedPlugins: []string{"mysql", "test-app"}},
{expectedCode: http.StatusOK, role: org.RoleAdmin, expectedPlugins: []string{"mysql", "test-app"}},
}
testName := func(tc testCase) string {
return fmt.Sprintf("List request returns %d when role: %s, isGrafanaAdmin: %t, filters: %v",
tc.expectedCode, tc.role, tc.isGrafanaAdmin, tc.filters)
}
testUser := func(role org.RoleType, isGrafanaAdmin bool) user.SignedInUser {
return user.SignedInUser{
UserID: 2,
OrgID: 2,
OrgName: "TestOrg2",
OrgRole: role,
Login: "testUser",
Name: "testUser",
Email: "testUser@example.org",
OrgCount: 1,
IsGrafanaAdmin: isGrafanaAdmin,
IsAnonymous: false,
}
}
for _, tc := range tcs {
sc := setupHTTPServer(t, true)
sc.hs.PluginSettings = &pluginSettings
sc.hs.pluginStore = pluginStore
sc.hs.pluginsUpdateChecker = updatechecker.ProvidePluginsService(sc.hs.Cfg, pluginStore)
setInitCtxSignedInUser(sc.initCtx, testUser(tc.role, tc.isGrafanaAdmin))
t.Run(testName(tc), func(t *testing.T) {
response := callAPI(sc.server, http.MethodGet, "/api/plugins/", nil, t)
require.Equal(t, tc.expectedCode, response.Code)
var res dtos.PluginList
err := json.NewDecoder(response.Body).Decode(&res)
require.NoError(t, err)
require.Len(t, res, len(tc.expectedPlugins))
for _, plugin := range res {
require.Contains(t, tc.expectedPlugins, plugin.Id)
}
})
}
}

View File

@ -7,7 +7,7 @@ import (
)
// The following variables cannot be constants, since they can be overridden through the -X link flag
var version = "7.5.0"
var version = "9.2.0"
var commit = "NA"
var buildBranch = "main"
var buildstamp string

View File

@ -1,19 +1,35 @@
package plugins
import (
"github.com/grafana/grafana/pkg/models"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
)
const (
// Plugins actions
ActionInstall = "plugins:install"
ActionWrite = "plugins:write"
// App Plugins actions
ActionAppAccess = "plugins.app:access"
)
var (
ScopeProvider = ac.NewScopeProvider("plugins")
// Protects access to the Configuration > Plugins page
AdminAccessEvaluator = ac.EvalAny(ac.EvalPermission(ActionWrite), ac.EvalPermission(ActionInstall))
)
func DeclareRBACRoles(service ac.Service) error {
func ReqCanAdminPlugins(cfg *setting.Cfg) func(rc *models.ReqContext) bool {
// Legacy handler that protects access to the Configuration > Plugins page
return func(rc *models.ReqContext) bool {
return rc.OrgRole == org.RoleAdmin || cfg.PluginAdminEnabled && rc.IsGrafanaAdmin
}
}
func DeclareRBACRoles(service ac.Service, cfg *setting.Cfg) error {
AppPluginsReader := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: ac.FixedRolePrefix + "plugins.app:reader",
@ -26,5 +42,34 @@ func DeclareRBACRoles(service ac.Service) error {
},
Grants: []string{string(org.RoleViewer)},
}
return service.DeclareFixedRoles(AppPluginsReader)
PluginsWriter := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: ac.FixedRolePrefix + "plugins:writer",
DisplayName: "Plugin Writer",
Description: "Enable and disable plugins and edit plugins' settings",
Group: "Plugins",
Permissions: []ac.Permission{
{Action: ActionWrite, Scope: ScopeProvider.GetResourceAllScope()},
},
},
Grants: []string{string(org.RoleAdmin)},
}
PluginsMaintainer := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: ac.FixedRolePrefix + "plugins:maintainer",
DisplayName: "Plugin Maintainer",
Description: "Install, uninstall plugins",
Group: "Plugins",
Permissions: []ac.Permission{
{Action: ActionInstall},
},
},
Grants: []string{ac.RoleGrafanaAdmin},
}
if !cfg.PluginAdminEnabled || cfg.PluginAdminExternalManageEnabled {
PluginsMaintainer.Grants = []string{}
}
return service.DeclareFixedRoles(AppPluginsReader, PluginsWriter, PluginsMaintainer)
}

View File

@ -161,6 +161,12 @@ var ReqOrgAdminOrEditor = func(c *models.ReqContext) bool {
return c.OrgRole == org.RoleAdmin || c.OrgRole == org.RoleEditor
}
// ReqHasRole generates a fallback to check whether the user has a role
// Note that while ReqOrgAdmin returns false for a Grafana Admin / Viewer, ReqHasRole(org.RoleAdmin) will return true
func ReqHasRole(role org.RoleType) func(c *models.ReqContext) bool {
return func(c *models.ReqContext) bool { return c.HasRole(role) }
}
func BuildPermissionsMap(permissions []Permission) map[string]bool {
permissionsMap := make(map[string]bool)
for _, p := range permissions {

View File

@ -48,11 +48,11 @@ func TestPlugins(t *testing.T) {
t.Run("Request is forbidden if not from an admin", func(t *testing.T) {
status, body := makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/install"))
assert.Equal(t, 403, status)
assert.Equal(t, "Permission denied", body["message"])
assert.Equal(t, "You'll need additional permissions to perform this action. Permissions needed: plugins:install", body["message"])
status, body = makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/uninstall"))
assert.Equal(t, 403, status)
assert.Equal(t, "Permission denied", body["message"])
assert.Equal(t, "You'll need additional permissions to perform this action. Permissions needed: plugins:install", body["message"])
})
t.Run("Request is not forbidden if from an admin", func(t *testing.T) {

View File

@ -1,5 +1,6 @@
import { PluginError, PluginMeta, renderMarkdown } from '@grafana/data';
import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { API_ROOT, GCOM_API_ROOT } from './constants';
import { isLocalPluginVisible, isRemotePluginVisible } from './helpers';
@ -91,7 +92,10 @@ async function getLocalPluginReadme(id: string): Promise<string> {
}
export async function getLocalPlugins(): Promise<LocalPlugin[]> {
const localPlugins: LocalPlugin[] = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 });
const localPlugins: LocalPlugin[] = await getBackendSrv().get(
`${API_ROOT}`,
accessControlQueryParam({ embedded: 0 })
);
return localPlugins.filter(isLocalPluginVisible);
}

View File

@ -2,9 +2,12 @@ import React from 'react';
import { PluginMeta } from '@grafana/data';
import { Button } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { updatePluginSettings } from '../../api';
import { usePluginConfig } from '../../hooks/usePluginConfig';
import { isOrgAdmin } from '../../permissions';
import { CatalogPlugin } from '../../types';
type Props = {
@ -18,6 +21,11 @@ export function GetStartedWithApp({ plugin }: Props): React.ReactElement | null
return null;
}
// Enforce RBAC
if (!contextSrv.hasAccessInMetadata(AccessControlAction.PluginsWrite, plugin, isOrgAdmin())) {
return null;
}
const { enabled, jsonData } = pluginConfig?.meta;
const enable = () =>

View File

@ -4,6 +4,8 @@ import React from 'react';
import { GrafanaTheme2, PluginType } from '@grafana/data';
import { config, featureEnabled } from '@grafana/runtime';
import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { getExternalManageLink, isInstallControlsEnabled } from '../../helpers';
import { isGrafanaAdmin } from '../../permissions';
@ -21,7 +23,7 @@ interface Props {
export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => {
const styles = useStyles2(getStyles);
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const hasPermission = isGrafanaAdmin();
const hasPermission = contextSrv.hasAccess(AccessControlAction.PluginsInstall, isGrafanaAdmin());
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const isCompatible = Boolean(latestCompatibleVersion);
const isInstallControlsDisabled = plugin.isCore || plugin.isDisabled || !isInstallControlsEnabled();

View File

@ -101,6 +101,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
signatureOrg,
signatureType,
hasUpdate,
accessControl,
} = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(type);
@ -127,6 +128,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
isEnterprise: false,
type,
error: error?.errorCode,
accessControl: accessControl,
};
}
@ -179,6 +181,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
updatedAt: remote?.updatedAt || local?.info.updated || '',
installedVersion,
error: error?.errorCode,
// Only local plugins have access control metadata
accessControl: local?.accessControl,
};
}

View File

@ -3,6 +3,8 @@ import { useLocation } from 'react-router-dom';
import { PluginIncludeType, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { usePluginConfig } from '../hooks/usePluginConfig';
import { isOrgAdmin } from '../permissions';
@ -21,7 +23,8 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
const { pathname } = useLocation();
const [tabs, defaultTab] = useMemo(() => {
const canConfigurePlugins = isOrgAdmin();
const canConfigurePlugins =
plugin && contextSrv.hasAccessInMetadata(AccessControlAction.PluginsWrite, plugin, isOrgAdmin());
const tabs: PluginDetailsTab[] = [...defaultTabs];
let defaultTab;
if (isPublished) {
@ -90,7 +93,7 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
}
return [tabs, defaultTab];
}, [pluginConfig, defaultTabs, pathname, isPublished]);
}, [plugin, pluginConfig, defaultTabs, pathname, isPublished]);
return {
error,

View File

@ -4,7 +4,13 @@ import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { PluginErrorCode, PluginSignatureStatus, PluginType, dateTimeFormatTimeAgo } from '@grafana/data';
import {
PluginErrorCode,
PluginSignatureStatus,
PluginType,
dateTimeFormatTimeAgo,
WithAccessControlMetadata,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
@ -45,6 +51,13 @@ jest.mock('../helpers.ts', () => ({
updatePanels: jest.fn(),
}));
jest.mock('app/core/core', () => ({
contextSrv: {
hasAccess: (action: string, fallBack: boolean) => true,
hasAccessInMetadata: (action: string, object: WithAccessControlMetadata, fallBack: boolean) => true,
},
}));
const renderPluginDetails = (
pluginOverride: Partial<CatalogPlugin>,
{
@ -83,7 +96,7 @@ const renderPluginDetails = (
describe('Plugin details page', () => {
const id = 'my-plugin';
const originalWindowLocation = window.location;
let dateNow: any;
let dateNow: jest.SpyInstance<number, []>;
beforeAll(() => {
dateNow = jest.spyOn(Date, 'now').mockImplementation(() => 1609470000000); // 2021-01-01 04:00:00

View File

@ -6,6 +6,7 @@ import {
PluginSignatureType,
PluginDependencies,
PluginErrorCode,
WithAccessControlMetadata,
} from '@grafana/data';
import { IconName } from '@grafana/ui';
import { StoreState, PluginsState } from 'app/types';
@ -31,7 +32,7 @@ export enum PluginIconName {
secretsmanager = 'key-skeleton-alt',
}
export interface CatalogPlugin {
export interface CatalogPlugin extends WithAccessControlMetadata {
description: string;
downloads: number;
hasUpdate: boolean;
@ -124,7 +125,7 @@ export type RemotePlugin = {
versionStatus: string;
};
export type LocalPlugin = {
export type LocalPlugin = WithAccessControlMetadata & {
category: string;
defaultNavUrl: string;
dev?: boolean;

View File

@ -115,6 +115,9 @@ export enum AccessControlAction {
ActionAPIKeysRead = 'apikeys:read',
ActionAPIKeysCreate = 'apikeys:create',
ActionAPIKeysDelete = 'apikeys:delete',
PluginsInstall = 'plugins:install',
PluginsWrite = 'plugins:write',
}
export interface Role {