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
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.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"] [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": [ "public/app/features/plugins/admin/pages/PluginDetails.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,11 @@ package api
import ( import (
"context" "context"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/pluginsettings"
) )
type fakePluginManager struct { 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 { func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO var result []plugins.PluginDTO
if len(pluginTypes) == 0 {
pluginTypes = plugins.PluginTypes
}
for _, v := range pr.plugins { for _, v := range pr.plugins {
for _, t := range pluginTypes { for _, t := range pluginTypes {
if v.Type == t { if v.Type == t {
@@ -72,3 +78,66 @@ type fakePluginStaticRouteResolver struct {
func (psrr *fakePluginStaticRouteResolver) Routes() []*plugins.StaticRoute { func (psrr *fakePluginStaticRouteResolver) Routes() []*plugins.StaticRoute {
return psrr.routes 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/org"
"github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/playlist"
"github.com/grafana/grafana/pkg/services/plugindashboards" "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" pref "github.com/grafana/grafana/pkg/services/preference"
"github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/provisioning"
publicdashboardsApi "github.com/grafana/grafana/pkg/services/publicdashboards/api" publicdashboardsApi "github.com/grafana/grafana/pkg/services/publicdashboards/api"
@@ -169,7 +169,7 @@ type HTTPServer struct {
commentsService *comments.Service commentsService *comments.Service
AlertNotificationService *alerting.AlertNotificationService AlertNotificationService *alerting.AlertNotificationService
dashboardsnapshotsService dashboardsnapshots.Service dashboardsnapshotsService dashboardsnapshots.Service
PluginSettings *pluginSettings.Service PluginSettings pluginSettings.Service
AvatarCacheServer *avatar.AvatarCacheServer AvatarCacheServer *avatar.AvatarCacheServer
preferenceService pref.Service preferenceService pref.Service
Csrf csrf.Service Csrf csrf.Service
@@ -218,7 +218,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, 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, avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service,
teamsPermissionsService accesscontrol.TeamPermissionsService, folderPermissionsService accesscontrol.FolderPermissionsService, teamsPermissionsService accesscontrol.TeamPermissionsService, folderPermissionsService accesscontrol.FolderPermissionsService,
dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service, 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{ configNodes = append(configNodes, &dtos.NavLink{
Text: "Plugins", Text: "Plugins",
Id: "plugins", Id: "plugins",

View File

@@ -13,10 +13,6 @@ import (
"sort" "sort"
"strings" "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-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response" "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/backendplugin"
"github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/plugins/storage" "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/services/pluginsettings"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
@@ -35,22 +34,33 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
typeFilter := c.Query("type") typeFilter := c.Query("type")
enabledFilter := c.Query("enabled") enabledFilter := c.Query("enabled")
embeddedFilter := c.Query("embedded") embeddedFilter := c.Query("embedded")
// "" => no filter
// "0" => filter out core plugins
// "1" => filter out non-core plugins
coreFilter := c.Query("core") 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 // Fallback to only letting admins list non-core plugins
hasAccess := accesscontrol.HasAccess(hs.AccessControl, c) reqOrgAdmin := ac.ReqHasRole(org.RoleAdmin)
if !hasAccess(accesscontrol.ReqOrgAdmin, accesscontrol.EvalPermission(datasources.ActionCreate)) && !c.HasRole(org.RoleAdmin) { hasAccess := ac.HasAccess(hs.AccessControl, c)
coreFilter = "1" canListNonCorePlugins := reqOrgAdmin(c) || hasAccess(reqOrgAdmin, ac.EvalAny(
} ac.EvalPermission(datasources.ActionCreate),
ac.EvalPermission(plugins.ActionInstall),
))
pluginSettingsMap, err := hs.pluginSettings(c.Req.Context(), c.OrgID) pluginSettingsMap, err := hs.pluginSettings(c.Req.Context(), c.OrgID)
if err != nil { 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) // Filter plugins
for _, pluginDef := range hs.pluginStore.Plugins(c.Req.Context()) { pluginDefinitions := hs.pluginStore.Plugins(c.Req.Context())
filteredPluginDefinitions := []plugins.PluginDTO{}
filteredPluginIDs := map[string]bool{}
for _, pluginDef := range pluginDefinitions {
// filter out app sub plugins // filter out app sub plugins
if embeddedFilter == "0" && pluginDef.IncludedInAppID != "" { if embeddedFilter == "0" && pluginDef.IncludedInAppID != "" {
continue continue
@@ -61,6 +71,17 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
continue 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 // filter on type
if typeFilter != "" && typeFilter != string(pluginDef.Type) { if typeFilter != "" && typeFilter != string(pluginDef.Type) {
continue continue
@@ -70,6 +91,29 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
continue 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{ listItem := dtos.PluginListItem{
Id: pluginDef.ID, Id: pluginDef.ID,
Name: pluginDef.Name, Name: pluginDef.Name,
@@ -82,6 +126,7 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
Signature: pluginDef.Signature, Signature: pluginDef.Signature,
SignatureType: pluginDef.SignatureType, SignatureType: pluginDef.SignatureType,
SignatureOrg: pluginDef.SignatureOrg, SignatureOrg: pluginDef.SignatureOrg,
AccessControl: pluginsMetadata[pluginDef.ID],
} }
update, exists := hs.pluginsUpdateChecker.HasUpdate(c.Req.Context(), 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 + "/" 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) 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. // 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. // We will need a different permission to allow users to configure the plugin without needing access to it.
if plugin.IsApp() { if plugin.IsApp() {
hasAccess := accesscontrol.HasAccess(hs.AccessControl, c) hasAccess := ac.HasAccess(hs.AccessControl, c)
if !hasAccess(accesscontrol.ReqSignedIn, if !hasAccess(ac.ReqSignedIn,
accesscontrol.EvalPermission(plugins.ActionAppAccess, plugins.ScopeProvider.GetResourceScope(plugin.ID))) { ac.EvalPermission(plugins.ActionAppAccess, plugins.ScopeProvider.GetResourceScope(plugin.ID))) {
return response.Error(http.StatusForbidden, "Access Denied", nil) return response.Error(http.StatusForbidden, "Access Denied", nil)
} }
} }

View File

@@ -16,12 +16,16 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend" "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"
"github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/log/logtest"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "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/org"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/services/quota/quotatest" "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/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest" "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) { func Test_GetPluginAssets(t *testing.T) {
pluginID := "test-plugin" pluginID := "test-plugin"
pluginDir := "." pluginDir := "."
@@ -342,3 +396,100 @@ func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryData
return backend.NewQueryDataResponse(), nil 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 // 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 commit = "NA"
var buildBranch = "main" var buildBranch = "main"
var buildstamp string var buildstamp string

View File

@@ -1,19 +1,35 @@
package plugins package plugins
import ( import (
"github.com/grafana/grafana/pkg/models"
ac "github.com/grafana/grafana/pkg/services/accesscontrol" ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
) )
const ( const (
// Plugins actions
ActionInstall = "plugins:install"
ActionWrite = "plugins:write"
// App Plugins actions
ActionAppAccess = "plugins.app:access" ActionAppAccess = "plugins.app:access"
) )
var ( var (
ScopeProvider = ac.NewScopeProvider("plugins") 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{ AppPluginsReader := ac.RoleRegistration{
Role: ac.RoleDTO{ Role: ac.RoleDTO{
Name: ac.FixedRolePrefix + "plugins.app:reader", Name: ac.FixedRolePrefix + "plugins.app:reader",
@@ -26,5 +42,34 @@ func DeclareRBACRoles(service ac.Service) error {
}, },
Grants: []string{string(org.RoleViewer)}, 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 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 { func BuildPermissionsMap(permissions []Permission) map[string]bool {
permissionsMap := make(map[string]bool) permissionsMap := make(map[string]bool)
for _, p := range permissions { 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) { 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")) status, body := makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/install"))
assert.Equal(t, 403, status) 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")) status, body = makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/uninstall"))
assert.Equal(t, 403, status) 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) { 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 { PluginError, PluginMeta, renderMarkdown } from '@grafana/data';
import { getBackendSrv, isFetchError } from '@grafana/runtime'; import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { accessControlQueryParam } from 'app/core/utils/accessControl';
import { API_ROOT, GCOM_API_ROOT } from './constants'; import { API_ROOT, GCOM_API_ROOT } from './constants';
import { isLocalPluginVisible, isRemotePluginVisible } from './helpers'; import { isLocalPluginVisible, isRemotePluginVisible } from './helpers';
@@ -91,7 +92,10 @@ async function getLocalPluginReadme(id: string): Promise<string> {
} }
export async function getLocalPlugins(): Promise<LocalPlugin[]> { 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); return localPlugins.filter(isLocalPluginVisible);
} }

View File

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

View File

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

View File

@@ -101,6 +101,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
signatureOrg, signatureOrg,
signatureType, signatureType,
hasUpdate, hasUpdate,
accessControl,
} = plugin; } = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(type); const isDisabled = !!error || isDisabledSecretsPlugin(type);
@@ -127,6 +128,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
isEnterprise: false, isEnterprise: false,
type, type,
error: error?.errorCode, error: error?.errorCode,
accessControl: accessControl,
}; };
} }
@@ -179,6 +181,8 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
updatedAt: remote?.updatedAt || local?.info.updated || '', updatedAt: remote?.updatedAt || local?.info.updated || '',
installedVersion, installedVersion,
error: error?.errorCode, 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 { PluginIncludeType, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { usePluginConfig } from '../hooks/usePluginConfig'; import { usePluginConfig } from '../hooks/usePluginConfig';
import { isOrgAdmin } from '../permissions'; import { isOrgAdmin } from '../permissions';
@@ -21,7 +23,8 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
const { pathname } = useLocation(); const { pathname } = useLocation();
const [tabs, defaultTab] = useMemo(() => { const [tabs, defaultTab] = useMemo(() => {
const canConfigurePlugins = isOrgAdmin(); const canConfigurePlugins =
plugin && contextSrv.hasAccessInMetadata(AccessControlAction.PluginsWrite, plugin, isOrgAdmin());
const tabs: PluginDetailsTab[] = [...defaultTabs]; const tabs: PluginDetailsTab[] = [...defaultTabs];
let defaultTab; let defaultTab;
if (isPublished) { if (isPublished) {
@@ -90,7 +93,7 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
} }
return [tabs, defaultTab]; return [tabs, defaultTab];
}, [pluginConfig, defaultTabs, pathname, isPublished]); }, [plugin, pluginConfig, defaultTabs, pathname, isPublished]);
return { return {
error, error,

View File

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

View File

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

View File

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