mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugins: Add backend check for app page role access (#78269)
* add backend check for roles * tidy * fix tests * incorporate rbac * fix linter * apply PR feedback * add tests * fix logic * add comment * apply PR feedback
This commit is contained in:
@@ -61,6 +61,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
|
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
|
||||||
reqEditorRole := middleware.ReqEditorRole
|
reqEditorRole := middleware.ReqEditorRole
|
||||||
reqOrgAdmin := middleware.ReqOrgAdmin
|
reqOrgAdmin := middleware.ReqOrgAdmin
|
||||||
|
reqRoleForAppRoute := middleware.RoleAppPluginAuth(hs.AccessControl, hs.pluginStore, hs.Features, hs.log)
|
||||||
reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg)
|
reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg)
|
||||||
redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg)
|
redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg)
|
||||||
authorize := ac.Middleware(hs.AccessControl)
|
authorize := ac.Middleware(hs.AccessControl)
|
||||||
@@ -140,8 +141,8 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
|
|
||||||
// App Root Page
|
// App Root Page
|
||||||
appPluginIDScope := pluginaccesscontrol.ScopeProvider.GetResourceScope(ac.Parameter(":id"))
|
appPluginIDScope := pluginaccesscontrol.ScopeProvider.GetResourceScope(ac.Parameter(":id"))
|
||||||
r.Get("/a/:id/*", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), hs.Index)
|
r.Get("/a/:id/*", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), reqSignedIn, reqRoleForAppRoute, hs.Index)
|
||||||
r.Get("/a/:id", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), hs.Index)
|
r.Get("/a/:id", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), reqSignedIn, reqRoleForAppRoute, hs.Index)
|
||||||
|
|
||||||
r.Get("/d/:uid/:slug", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
r.Get("/d/:uid/:slug", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
||||||
r.Get("/d/:uid", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
r.Get("/d/:uid", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/middleware/cookies"
|
"github.com/grafana/grafana/pkg/middleware/cookies"
|
||||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/auth"
|
"github.com/grafana/grafana/pkg/services/auth"
|
||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
@@ -97,6 +101,54 @@ func CanAdminPlugins(cfg *setting.Cfg, accessControl ac.AccessControl) func(c *c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, features featuremgmt.FeatureToggles,
|
||||||
|
logger log.Logger) func(c *contextmodel.ReqContext) {
|
||||||
|
return func(c *contextmodel.ReqContext) {
|
||||||
|
pluginID := web.Params(c.Req)[":id"]
|
||||||
|
p, exists := ps.Plugin(c.Req.Context(), pluginID)
|
||||||
|
if !exists {
|
||||||
|
// The frontend will handle app not found appropriately
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permitted := true
|
||||||
|
path := normalizeIncludePath(c.Req.URL.Path)
|
||||||
|
hasAccess := ac.HasAccess(accessControl, c)
|
||||||
|
for _, i := range p.Includes {
|
||||||
|
if i.Type != "page" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(i.Path)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to parse include path", "pluginId", pluginID, "include", i.Name, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizeIncludePath(u.Path) == path {
|
||||||
|
useRBAC := features.IsEnabledGlobally(featuremgmt.FlagAccessControlOnCall) && i.RequiresRBACAction()
|
||||||
|
if useRBAC && !hasAccess(ac.EvalPermission(i.Action)) {
|
||||||
|
logger.Debug("Plugin include is covered by RBAC, user doesn't have access", "plugin", pluginID, "include", i.Name)
|
||||||
|
permitted = false
|
||||||
|
break
|
||||||
|
} else if !useRBAC && !c.HasUserRole(i.Role) {
|
||||||
|
permitted = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !permitted {
|
||||||
|
accessForbidden(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIncludePath(p string) string {
|
||||||
|
return strings.TrimPrefix(filepath.Clean(p), "/")
|
||||||
|
}
|
||||||
|
|
||||||
func RoleAuth(roles ...org.RoleType) web.Handler {
|
func RoleAuth(roles ...org.RoleType) web.Handler {
|
||||||
return func(c *contextmodel.ReqContext) {
|
return func(c *contextmodel.ReqContext) {
|
||||||
ok := false
|
ok := false
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log/logtest"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
"github.com/grafana/grafana/pkg/services/authn/authntest"
|
"github.com/grafana/grafana/pkg/services/authn/authntest"
|
||||||
"github.com/grafana/grafana/pkg/services/contexthandler"
|
"github.com/grafana/grafana/pkg/services/contexthandler"
|
||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
@@ -150,6 +155,185 @@ func TestAuth_Middleware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoleAppPluginAuth(t *testing.T) {
|
||||||
|
t.Run("Verify user's role when requesting app route which requires role", func(t *testing.T) {
|
||||||
|
appSubURL := setting.AppSubUrl
|
||||||
|
setting.AppSubUrl = "/grafana/"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
setting.AppSubUrl = appSubURL
|
||||||
|
})
|
||||||
|
|
||||||
|
tcs := []struct {
|
||||||
|
roleRequired org.RoleType
|
||||||
|
role org.RoleType
|
||||||
|
expStatus int
|
||||||
|
expBody string
|
||||||
|
expLocation string
|
||||||
|
}{
|
||||||
|
{roleRequired: org.RoleViewer, role: org.RoleAdmin, expStatus: http.StatusOK, expBody: ""},
|
||||||
|
{roleRequired: org.RoleAdmin, role: org.RoleAdmin, expStatus: http.StatusOK, expBody: ""},
|
||||||
|
{roleRequired: org.RoleAdmin, role: org.RoleViewer, expStatus: http.StatusFound, expBody: "<a href=\"/grafana/\">Found</a>.\n\n", expLocation: "/grafana/"},
|
||||||
|
{roleRequired: "", role: org.RoleViewer, expStatus: http.StatusOK, expBody: ""},
|
||||||
|
{roleRequired: org.RoleEditor, role: "", expStatus: http.StatusFound, expBody: "<a href=\"/grafana/\">Found</a>.\n\n", expLocation: "/grafana/"},
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = "/a/test-app/test"
|
||||||
|
for i, tc := range tcs {
|
||||||
|
t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) {
|
||||||
|
ps := pluginstore.NewFakePluginStore(pluginstore.Plugin{
|
||||||
|
JSONData: plugins.JSONData{
|
||||||
|
ID: "test-app",
|
||||||
|
Includes: []*plugins.Includes{
|
||||||
|
{
|
||||||
|
Type: "page",
|
||||||
|
Role: tc.roleRequired,
|
||||||
|
Path: path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
middlewareScenario(t, t.Name(), func(t *testing.T, sc *scenarioContext) {
|
||||||
|
sc.withIdentity(&authn.Identity{
|
||||||
|
OrgRoles: map[int64]org.RoleType{
|
||||||
|
0: tc.role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
features := featuremgmt.WithFeatures()
|
||||||
|
logger := &logtest.Fake{}
|
||||||
|
ac := &actest.FakeAccessControl{}
|
||||||
|
|
||||||
|
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, ps, features, logger), func(c *contextmodel.ReqContext) {
|
||||||
|
c.JSON(http.StatusOK, map[string]interface{}{})
|
||||||
|
})
|
||||||
|
sc.fakeReq("GET", path).exec()
|
||||||
|
assert.Equal(t, tc.expStatus, sc.resp.Code)
|
||||||
|
assert.Equal(t, tc.expBody, sc.resp.Body.String())
|
||||||
|
assert.Equal(t, tc.expLocation, sc.resp.Header().Get("Location"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// We return success in this case because the frontend takes care of rendering the 404 page
|
||||||
|
middlewareScenario(t, "Plugin is not found returns success", func(t *testing.T, sc *scenarioContext) {
|
||||||
|
sc.withIdentity(&authn.Identity{
|
||||||
|
OrgRoles: map[int64]org.RoleType{
|
||||||
|
0: org.RoleViewer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
features := featuremgmt.WithFeatures()
|
||||||
|
logger := &logtest.Fake{}
|
||||||
|
ac := &actest.FakeAccessControl{}
|
||||||
|
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, &pluginstore.FakePluginStore{}, features, logger), func(c *contextmodel.ReqContext) {
|
||||||
|
c.JSON(http.StatusOK, map[string]interface{}{})
|
||||||
|
})
|
||||||
|
sc.fakeReq("GET", "/a/test-app/test").exec()
|
||||||
|
assert.Equal(t, 200, sc.resp.Code)
|
||||||
|
assert.Equal(t, "", sc.resp.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// We return success in this case because the frontend takes care of rendering the right page based on its router
|
||||||
|
middlewareScenario(t, "Plugin page not found returns success", func(t *testing.T, sc *scenarioContext) {
|
||||||
|
sc.withIdentity(&authn.Identity{
|
||||||
|
OrgRoles: map[int64]org.RoleType{
|
||||||
|
0: org.RoleViewer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
features := featuremgmt.WithFeatures()
|
||||||
|
logger := &logtest.Fake{}
|
||||||
|
ac := &actest.FakeAccessControl{}
|
||||||
|
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, pluginstore.NewFakePluginStore(pluginstore.Plugin{
|
||||||
|
JSONData: plugins.JSONData{
|
||||||
|
ID: "test-app",
|
||||||
|
Includes: []*plugins.Includes{
|
||||||
|
{
|
||||||
|
Type: "page",
|
||||||
|
Role: org.RoleViewer,
|
||||||
|
Path: "/a/test-app/test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), features, logger), func(c *contextmodel.ReqContext) {
|
||||||
|
c.JSON(http.StatusOK, map[string]interface{}{})
|
||||||
|
})
|
||||||
|
sc.fakeReq("GET", "/a/test-app/notExistingPath").exec()
|
||||||
|
assert.Equal(t, 200, sc.resp.Code)
|
||||||
|
assert.Equal(t, "", sc.resp.Body.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Plugin include with RBAC", func(t *testing.T) {
|
||||||
|
tcs := []struct {
|
||||||
|
name string
|
||||||
|
evalResult bool
|
||||||
|
evalErr error
|
||||||
|
expStatus int
|
||||||
|
expBody string
|
||||||
|
expLocation string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Unsuccessful RBAC eval will result in a redirect",
|
||||||
|
evalResult: false,
|
||||||
|
expStatus: 302,
|
||||||
|
expBody: "<a href=\"/\">Found</a>.\n\n",
|
||||||
|
expLocation: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "An RBAC eval error will result in a redirect",
|
||||||
|
evalErr: errors.New("eval error"),
|
||||||
|
expStatus: 302,
|
||||||
|
expBody: "<a href=\"/\">Found</a>.\n\n",
|
||||||
|
expLocation: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Successful RBAC eval will result in a successful request",
|
||||||
|
evalResult: true,
|
||||||
|
expStatus: 200,
|
||||||
|
expBody: "",
|
||||||
|
expLocation: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tcs {
|
||||||
|
middlewareScenario(t, "Plugin include with RBAC", func(t *testing.T, sc *scenarioContext) {
|
||||||
|
sc.withIdentity(&authn.Identity{
|
||||||
|
OrgRoles: map[int64]org.RoleType{
|
||||||
|
0: org.RoleViewer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger := &logtest.Fake{}
|
||||||
|
features := featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall)
|
||||||
|
ac := &actest.FakeAccessControl{
|
||||||
|
ExpectedEvaluate: tc.evalResult,
|
||||||
|
ExpectedErr: tc.evalErr,
|
||||||
|
}
|
||||||
|
path := "/a/test-app/test"
|
||||||
|
ps := pluginstore.NewFakePluginStore(pluginstore.Plugin{
|
||||||
|
JSONData: plugins.JSONData{
|
||||||
|
ID: "test-app",
|
||||||
|
Includes: []*plugins.Includes{
|
||||||
|
{
|
||||||
|
Type: "page",
|
||||||
|
Role: org.RoleViewer,
|
||||||
|
Path: path,
|
||||||
|
Action: "test-app.test:read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, ps, features, logger), func(c *contextmodel.ReqContext) {
|
||||||
|
c.JSON(http.StatusOK, map[string]interface{}{})
|
||||||
|
})
|
||||||
|
sc.fakeReq("GET", path).exec()
|
||||||
|
assert.Equal(t, tc.expStatus, sc.resp.Code)
|
||||||
|
assert.Equal(t, tc.expBody, sc.resp.Body.String())
|
||||||
|
assert.Equal(t, tc.expLocation, sc.resp.Header().Get("Location"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveForceLoginparams(t *testing.T) {
|
func TestRemoveForceLoginparams(t *testing.T) {
|
||||||
tcs := []struct {
|
tcs := []struct {
|
||||||
inp string
|
inp string
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ type FakePluginStore struct {
|
|||||||
PluginList []Plugin
|
PluginList []Plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewFakePluginStore(ps ...Plugin) *FakePluginStore {
|
||||||
|
return &FakePluginStore{
|
||||||
|
PluginList: ps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (pr *FakePluginStore) Plugin(_ context.Context, pluginID string) (Plugin, bool) {
|
func (pr *FakePluginStore) Plugin(_ context.Context, pluginID string) (Plugin, bool) {
|
||||||
for _, v := range pr.PluginList {
|
for _, v := range pr.PluginList {
|
||||||
if v.ID == pluginID {
|
if v.ID == pluginID {
|
||||||
|
|||||||
Reference in New Issue
Block a user