mirror of
https://github.com/grafana/grafana.git
synced 2025-01-27 16:57:14 -06:00
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:
parent
8c081d4523
commit
101349fe49
@ -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"]
|
||||
],
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 = () =>
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user