diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index b43f32927fd..c590b46f932 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -4,12 +4,13 @@ import { Provider } from 'react-redux'; import { Route, Router } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; -import { AppPlugin, PluginType, AppRootProps, NavModelItem } from '@grafana/data'; +import { AppPlugin, PluginType, AppRootProps, NavModelItem, PluginIncludeType, OrgRole } from '@grafana/data'; import { getMockPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { locationService, setEchoSrv } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; import { RouteDescriptor } from 'app/core/navigation/types'; +import { contextSrv } from 'app/core/services/context_srv'; import { Echo } from 'app/core/services/echo/Echo'; import { configureStore } from 'app/store/configureStore'; @@ -43,7 +44,7 @@ class RootComponent extends Component { } } -function renderUnderRouter() { +async function renderUnderRouter(page = '') { const appPluginNavItem: NavModelItem = { text: 'App', id: 'plugin-page-app', @@ -68,17 +69,21 @@ function renderUnderRouter() { appPluginNavItem.parentItem = appsSection; + const pagePath = page ? `/${page}` : ''; const store = configureStore(); const route = { component: () => , } as unknown as RouteDescriptor; - locationService.push('/a/my-awesome-plugin'); + + await act(async () => { + locationService.push(`/a/my-awesome-plugin${pagePath}`); + }); render( - } /> + } /> @@ -97,7 +102,7 @@ describe('AppRootPage', () => { enabled: true, }); - it('should not render component if not at plugin path', async () => { + it('should not render the component if we are not under a plugin path', async () => { getPluginSettingsMock.mockResolvedValue(pluginMeta); const plugin = new AppPlugin(); @@ -106,23 +111,150 @@ describe('AppRootPage', () => { importAppPluginMock.mockResolvedValue(plugin); - renderUnderRouter(); - + // Renders once for the first time + await renderUnderRouter(); expect(await screen.findByText('my great component')).toBeVisible(); - - // renders the first time expect(RootComponent.timesRendered).toEqual(1); + // Does not render again when navigating to a non-plugin path await act(async () => { locationService.push('/foo'); }); - expect(RootComponent.timesRendered).toEqual(1); + // Renders it again when navigating back to a plugin path await act(async () => { locationService.push('/a/my-awesome-plugin'); }); - expect(RootComponent.timesRendered).toEqual(2); }); + + describe('When accessing using different roles', () => { + beforeEach(() => { + const pluginMetaWithIncludes = getMockPlugin({ + id: 'my-awesome-plugin', + type: PluginType.app, + enabled: true, + includes: [ + { + type: PluginIncludeType.page, + name: 'Awesome page 1', + path: '/a/my-awesome-plugin/viewer-page', + role: 'Viewer', + }, + { + type: PluginIncludeType.page, + name: 'Awesome page 2', + path: '/a/my-awesome-plugin/editor-page', + role: 'Editor', + }, + { + type: PluginIncludeType.page, + name: 'Awesome page 2', + path: '/a/my-awesome-plugin/admin-page', + role: 'Admin', + }, + { + type: PluginIncludeType.page, + name: 'Awesome page with mistake', + path: '/a/my-awesome-plugin/mistake-page', + role: 'NotExistingRole', + }, + { + type: PluginIncludeType.page, + name: 'Awesome page 2', + path: '/a/my-awesome-plugin/page-without-role', + }, + ], + }); + + getPluginSettingsMock.mockResolvedValue(pluginMetaWithIncludes); + + const plugin = new AppPlugin(); + plugin.meta = pluginMetaWithIncludes; + plugin.root = RootComponent; + + importAppPluginMock.mockResolvedValue(plugin); + }); + + it('an User should not be able to see page with not existing role', async () => { + contextSrv.user.orgRole = OrgRole.Editor; + + await renderUnderRouter('mistake-page'); + expect(await screen.findByText('Access denied')).toBeVisible(); + }); + + it('a Viewer should only have access to pages with "Viewer" roles', async () => { + contextSrv.user.orgRole = OrgRole.Viewer; + + // Viewer has access to a plugin entry page by default + await renderUnderRouter(''); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Viewer has access to a page without roles + await renderUnderRouter('page-without-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Viewer has access to Viewer page + await renderUnderRouter('viewer-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Viewer does not have access to Editor page + await renderUnderRouter('editor-page'); + expect(await screen.findByText('Access denied')).toBeVisible(); + + // Viewer does not have access to a Admin page + await renderUnderRouter('admin-page'); + expect(await screen.findByText('Access denied')).toBeVisible(); + }); + + it('an Editor should have access to pages with both "Viewer" and "Editor" roles', async () => { + contextSrv.user.orgRole = OrgRole.Editor; + contextSrv.isEditor = true; + + // Viewer has access to a plugin entry page by default + await renderUnderRouter(''); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Editor has access to a page without roles + await renderUnderRouter('page-without-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Editor has access to Viewer page + await renderUnderRouter('viewer-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Editor has access to Editor page + await renderUnderRouter('editor-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Editor does not have access to a Admin page + await renderUnderRouter('admin-page'); + expect(await screen.findByText('Access denied')).toBeVisible(); + }); + + it('a Grafana Admin should be able to see any page', async () => { + contextSrv.isGrafanaAdmin = true; + + // Viewer has access to a plugin entry page + await renderUnderRouter(''); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Admin has access to a page without roles + await renderUnderRouter('page-without-role'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Admin has access to Viewer page + await renderUnderRouter('viewer-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Admin has access to Editor page + await renderUnderRouter('editor-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + + // Admin has access to a Admin page + await renderUnderRouter('admin-page'); + expect(await screen.findByText('my great component')).toBeVisible(); + }); + }); }); diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index c27159a4ed7..182c177ca1e 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -3,12 +3,13 @@ import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import { useLocation, useRouteMatch } from 'react-router-dom'; -import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, PluginType } from '@grafana/data'; +import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, OrgRole, PluginType } from '@grafana/data'; import { config, locationSearchToObject } from '@grafana/runtime'; +import { Alert } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { appEvents } from 'app/core/core'; +import { appEvents, contextSrv } from 'app/core/core'; import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/core/navigation/errorModels'; import { getPluginSettings } from '../pluginSettings'; @@ -81,6 +82,39 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) { /> ); + // Because of the fallback at plugin routes, we need to check + // if the user has permissions to see the plugin page. + const userHasPermissionsToPluginPage = () => { + // Check if plugin does not have any configurations or the user is Grafana Admin + if (!plugin.meta?.includes || contextSrv.isGrafanaAdmin || contextSrv.user.orgRole === OrgRole.Admin) { + return true; + } + + const pluginInclude = plugin.meta?.includes.find((include) => include.path === pluginRoot.props.path); + // Check if include configuration contains current path + if (!pluginInclude) { + return true; + } + const pathRole: string = pluginInclude?.role || ''; + // Check if role exists and give access to Editor to be able to see Viewer pages + if (!pathRole || (contextSrv.isEditor && pathRole === OrgRole.Viewer)) { + return true; + } + return contextSrv.hasRole(pathRole); + }; + + const AccessDenied = () => { + return ( + + You do not have permission to see this page. + + ); + }; + + if (!userHasPermissionsToPluginPage()) { + return ; + } + if (!pluginNav) { return {pluginRoot}; }