Skip to content

Commit 0b511aa

Browse files
authored
Plugins: Add backend check for app page role access (grafana#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
1 parent 68189cd commit 0b511aa

File tree

4 files changed

+245
-2
lines changed

4 files changed

+245
-2
lines changed

pkg/api/api.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func (hs *HTTPServer) registerRoutes() {
6161
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
6262
reqEditorRole := middleware.ReqEditorRole
6363
reqOrgAdmin := middleware.ReqOrgAdmin
64+
reqRoleForAppRoute := middleware.RoleAppPluginAuth(hs.AccessControl, hs.pluginStore, hs.Features, hs.log)
6465
reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg)
6566
redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg)
6667
authorize := ac.Middleware(hs.AccessControl)
@@ -140,8 +141,8 @@ func (hs *HTTPServer) registerRoutes() {
140141

141142
// App Root Page
142143
appPluginIDScope := pluginaccesscontrol.ScopeProvider.GetResourceScope(ac.Parameter(":id"))
143-
r.Get("/a/:id/*", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), hs.Index)
144-
r.Get("/a/:id", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), hs.Index)
144+
r.Get("/a/:id/*", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), reqSignedIn, reqRoleForAppRoute, hs.Index)
145+
r.Get("/a/:id", authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, appPluginIDScope)), reqSignedIn, reqRoleForAppRoute, hs.Index)
145146

146147
r.Get("/d/:uid/:slug", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)
147148
r.Get("/d/:uid", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index)

pkg/middleware/auth.go

+52
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@ import (
44
"errors"
55
"net/http"
66
"net/url"
7+
"path/filepath"
78
"regexp"
89
"strconv"
910
"strings"
1011

12+
"github.com/grafana/grafana/pkg/infra/log"
1113
"github.com/grafana/grafana/pkg/middleware/cookies"
1214
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
1315
"github.com/grafana/grafana/pkg/services/auth"
1416
"github.com/grafana/grafana/pkg/services/authn"
1517
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
18+
"github.com/grafana/grafana/pkg/services/featuremgmt"
1619
"github.com/grafana/grafana/pkg/services/org"
1720
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
21+
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
1822
"github.com/grafana/grafana/pkg/setting"
1923
"github.com/grafana/grafana/pkg/web"
2024
)
@@ -97,6 +101,54 @@ func CanAdminPlugins(cfg *setting.Cfg, accessControl ac.AccessControl) func(c *c
97101
}
98102
}
99103

104+
func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, features featuremgmt.FeatureToggles,
105+
logger log.Logger) func(c *contextmodel.ReqContext) {
106+
return func(c *contextmodel.ReqContext) {
107+
pluginID := web.Params(c.Req)[":id"]
108+
p, exists := ps.Plugin(c.Req.Context(), pluginID)
109+
if !exists {
110+
// The frontend will handle app not found appropriately
111+
return
112+
}
113+
114+
permitted := true
115+
path := normalizeIncludePath(c.Req.URL.Path)
116+
hasAccess := ac.HasAccess(accessControl, c)
117+
for _, i := range p.Includes {
118+
if i.Type != "page" {
119+
continue
120+
}
121+
122+
u, err := url.Parse(i.Path)
123+
if err != nil {
124+
logger.Error("failed to parse include path", "pluginId", pluginID, "include", i.Name, "err", err)
125+
continue
126+
}
127+
128+
if normalizeIncludePath(u.Path) == path {
129+
useRBAC := features.IsEnabledGlobally(featuremgmt.FlagAccessControlOnCall) && i.RequiresRBACAction()
130+
if useRBAC && !hasAccess(ac.EvalPermission(i.Action)) {
131+
logger.Debug("Plugin include is covered by RBAC, user doesn't have access", "plugin", pluginID, "include", i.Name)
132+
permitted = false
133+
break
134+
} else if !useRBAC && !c.HasUserRole(i.Role) {
135+
permitted = false
136+
break
137+
}
138+
}
139+
}
140+
141+
if !permitted {
142+
accessForbidden(c)
143+
return
144+
}
145+
}
146+
}
147+
148+
func normalizeIncludePath(p string) string {
149+
return strings.TrimPrefix(filepath.Clean(p), "/")
150+
}
151+
100152
func RoleAuth(roles ...org.RoleType) web.Handler {
101153
return func(c *contextmodel.ReqContext) {
102154
ok := false

pkg/middleware/auth_test.go

+184
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,17 @@ import (
1010
"github.com/stretchr/testify/assert"
1111
"github.com/stretchr/testify/require"
1212

13+
"github.com/grafana/grafana/pkg/infra/log/logtest"
1314
"github.com/grafana/grafana/pkg/infra/tracing"
15+
"github.com/grafana/grafana/pkg/plugins"
16+
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
1417
"github.com/grafana/grafana/pkg/services/authn"
1518
"github.com/grafana/grafana/pkg/services/authn/authntest"
1619
"github.com/grafana/grafana/pkg/services/contexthandler"
1720
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
1821
"github.com/grafana/grafana/pkg/services/featuremgmt"
22+
"github.com/grafana/grafana/pkg/services/org"
23+
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
1924
"github.com/grafana/grafana/pkg/setting"
2025
"github.com/grafana/grafana/pkg/web"
2126
)
@@ -150,6 +155,185 @@ func TestAuth_Middleware(t *testing.T) {
150155
}
151156
}
152157

158+
func TestRoleAppPluginAuth(t *testing.T) {
159+
t.Run("Verify user's role when requesting app route which requires role", func(t *testing.T) {
160+
appSubURL := setting.AppSubUrl
161+
setting.AppSubUrl = "/grafana/"
162+
t.Cleanup(func() {
163+
setting.AppSubUrl = appSubURL
164+
})
165+
166+
tcs := []struct {
167+
roleRequired org.RoleType
168+
role org.RoleType
169+
expStatus int
170+
expBody string
171+
expLocation string
172+
}{
173+
{roleRequired: org.RoleViewer, role: org.RoleAdmin, expStatus: http.StatusOK, expBody: ""},
174+
{roleRequired: org.RoleAdmin, role: org.RoleAdmin, expStatus: http.StatusOK, expBody: ""},
175+
{roleRequired: org.RoleAdmin, role: org.RoleViewer, expStatus: http.StatusFound, expBody: "<a href=\"/grafana/\">Found</a>.\n\n", expLocation: "/grafana/"},
176+
{roleRequired: "", role: org.RoleViewer, expStatus: http.StatusOK, expBody: ""},
177+
{roleRequired: org.RoleEditor, role: "", expStatus: http.StatusFound, expBody: "<a href=\"/grafana/\">Found</a>.\n\n", expLocation: "/grafana/"},
178+
}
179+
180+
const path = "/a/test-app/test"
181+
for i, tc := range tcs {
182+
t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) {
183+
ps := pluginstore.NewFakePluginStore(pluginstore.Plugin{
184+
JSONData: plugins.JSONData{
185+
ID: "test-app",
186+
Includes: []*plugins.Includes{
187+
{
188+
Type: "page",
189+
Role: tc.roleRequired,
190+
Path: path,
191+
},
192+
},
193+
},
194+
})
195+
196+
middlewareScenario(t, t.Name(), func(t *testing.T, sc *scenarioContext) {
197+
sc.withIdentity(&authn.Identity{
198+
OrgRoles: map[int64]org.RoleType{
199+
0: tc.role,
200+
},
201+
})
202+
features := featuremgmt.WithFeatures()
203+
logger := &logtest.Fake{}
204+
ac := &actest.FakeAccessControl{}
205+
206+
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, ps, features, logger), func(c *contextmodel.ReqContext) {
207+
c.JSON(http.StatusOK, map[string]interface{}{})
208+
})
209+
sc.fakeReq("GET", path).exec()
210+
assert.Equal(t, tc.expStatus, sc.resp.Code)
211+
assert.Equal(t, tc.expBody, sc.resp.Body.String())
212+
assert.Equal(t, tc.expLocation, sc.resp.Header().Get("Location"))
213+
})
214+
})
215+
}
216+
})
217+
218+
// We return success in this case because the frontend takes care of rendering the 404 page
219+
middlewareScenario(t, "Plugin is not found returns success", func(t *testing.T, sc *scenarioContext) {
220+
sc.withIdentity(&authn.Identity{
221+
OrgRoles: map[int64]org.RoleType{
222+
0: org.RoleViewer,
223+
},
224+
})
225+
features := featuremgmt.WithFeatures()
226+
logger := &logtest.Fake{}
227+
ac := &actest.FakeAccessControl{}
228+
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, &pluginstore.FakePluginStore{}, features, logger), func(c *contextmodel.ReqContext) {
229+
c.JSON(http.StatusOK, map[string]interface{}{})
230+
})
231+
sc.fakeReq("GET", "/a/test-app/test").exec()
232+
assert.Equal(t, 200, sc.resp.Code)
233+
assert.Equal(t, "", sc.resp.Body.String())
234+
})
235+
236+
// We return success in this case because the frontend takes care of rendering the right page based on its router
237+
middlewareScenario(t, "Plugin page not found returns success", func(t *testing.T, sc *scenarioContext) {
238+
sc.withIdentity(&authn.Identity{
239+
OrgRoles: map[int64]org.RoleType{
240+
0: org.RoleViewer,
241+
},
242+
})
243+
features := featuremgmt.WithFeatures()
244+
logger := &logtest.Fake{}
245+
ac := &actest.FakeAccessControl{}
246+
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, pluginstore.NewFakePluginStore(pluginstore.Plugin{
247+
JSONData: plugins.JSONData{
248+
ID: "test-app",
249+
Includes: []*plugins.Includes{
250+
{
251+
Type: "page",
252+
Role: org.RoleViewer,
253+
Path: "/a/test-app/test",
254+
},
255+
},
256+
},
257+
}), features, logger), func(c *contextmodel.ReqContext) {
258+
c.JSON(http.StatusOK, map[string]interface{}{})
259+
})
260+
sc.fakeReq("GET", "/a/test-app/notExistingPath").exec()
261+
assert.Equal(t, 200, sc.resp.Code)
262+
assert.Equal(t, "", sc.resp.Body.String())
263+
})
264+
265+
t.Run("Plugin include with RBAC", func(t *testing.T) {
266+
tcs := []struct {
267+
name string
268+
evalResult bool
269+
evalErr error
270+
expStatus int
271+
expBody string
272+
expLocation string
273+
}{
274+
{
275+
name: "Unsuccessful RBAC eval will result in a redirect",
276+
evalResult: false,
277+
expStatus: 302,
278+
expBody: "<a href=\"/\">Found</a>.\n\n",
279+
expLocation: "/",
280+
},
281+
{
282+
name: "An RBAC eval error will result in a redirect",
283+
evalErr: errors.New("eval error"),
284+
expStatus: 302,
285+
expBody: "<a href=\"/\">Found</a>.\n\n",
286+
expLocation: "/",
287+
},
288+
{
289+
name: "Successful RBAC eval will result in a successful request",
290+
evalResult: true,
291+
expStatus: 200,
292+
expBody: "",
293+
expLocation: "",
294+
},
295+
}
296+
297+
for _, tc := range tcs {
298+
middlewareScenario(t, "Plugin include with RBAC", func(t *testing.T, sc *scenarioContext) {
299+
sc.withIdentity(&authn.Identity{
300+
OrgRoles: map[int64]org.RoleType{
301+
0: org.RoleViewer,
302+
},
303+
})
304+
logger := &logtest.Fake{}
305+
features := featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall)
306+
ac := &actest.FakeAccessControl{
307+
ExpectedEvaluate: tc.evalResult,
308+
ExpectedErr: tc.evalErr,
309+
}
310+
path := "/a/test-app/test"
311+
ps := pluginstore.NewFakePluginStore(pluginstore.Plugin{
312+
JSONData: plugins.JSONData{
313+
ID: "test-app",
314+
Includes: []*plugins.Includes{
315+
{
316+
Type: "page",
317+
Role: org.RoleViewer,
318+
Path: path,
319+
Action: "test-app.test:read",
320+
},
321+
},
322+
},
323+
})
324+
325+
sc.m.Get("/a/:id/*", RoleAppPluginAuth(ac, ps, features, logger), func(c *contextmodel.ReqContext) {
326+
c.JSON(http.StatusOK, map[string]interface{}{})
327+
})
328+
sc.fakeReq("GET", path).exec()
329+
assert.Equal(t, tc.expStatus, sc.resp.Code)
330+
assert.Equal(t, tc.expBody, sc.resp.Body.String())
331+
assert.Equal(t, tc.expLocation, sc.resp.Header().Get("Location"))
332+
})
333+
}
334+
})
335+
}
336+
153337
func TestRemoveForceLoginparams(t *testing.T) {
154338
tcs := []struct {
155339
inp string

pkg/services/pluginsintegration/pluginstore/fake.go

+6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ type FakePluginStore struct {
1010
PluginList []Plugin
1111
}
1212

13+
func NewFakePluginStore(ps ...Plugin) *FakePluginStore {
14+
return &FakePluginStore{
15+
PluginList: ps,
16+
}
17+
}
18+
1319
func (pr *FakePluginStore) Plugin(_ context.Context, pluginID string) (Plugin, bool) {
1420
for _, v := range pr.PluginList {
1521
if v.ID == pluginID {

0 commit comments

Comments
 (0)