From 7966e3583e680456eec831c46ceaa352f264bd0d Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Thu, 3 Mar 2022 15:18:27 +0100 Subject: [PATCH] Access control: Dashboard and folder permissions frontend (#45094) --- .betterer.results | 2 +- .../core/components/Select/FolderPicker.tsx | 9 +++- .../dashboard/components/DashNav/DashNav.tsx | 5 +- .../AccessControlDashboardPermissions.tsx | 23 ++++++++ .../DashboardSettings/DashboardSettings.tsx | 23 +++++--- .../DashboardSettings/GeneralSettings.tsx | 2 +- .../SaveDashboard/useDashboardSave.tsx | 8 ++- .../dashboard/state/DashboardModel.ts | 4 +- .../AccessControlFolderPermissions.tsx | 52 +++++++++++++++++++ .../folders/FolderSettingsPage.test.tsx | 2 + .../features/folders/FolderSettingsPage.tsx | 2 +- public/app/features/folders/state/actions.ts | 2 + public/app/features/folders/state/navModel.ts | 1 + .../features/folders/state/reducers.test.ts | 1 + public/app/features/folders/state/reducers.ts | 1 + public/app/routes/routes.tsx | 12 +++-- public/app/types/accessControl.ts | 14 +++++ public/app/types/folders.ts | 2 + 18 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx create mode 100644 public/app/features/folders/AccessControlFolderPermissions.tsx diff --git a/.betterer.results b/.betterer.results index 2c80cf6efe4..baea248389d 100644 --- a/.betterer.results +++ b/.betterer.results @@ -269,7 +269,7 @@ exports[`no enzyme tests`] = { "public/app/features/explore/TimeSyncButton.test.tsx:4230066214": [ [2, 17, 13, "RegExp match", "2409514259"] ], - "public/app/features/folders/FolderSettingsPage.test.tsx:1751147194": [ + "public/app/features/folders/FolderSettingsPage.test.tsx:3884290298": [ [2, 19, 13, "RegExp match", "2409514259"] ], "public/app/features/invites/InviteesTable.test.tsx:3077684439": [ diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index e471e680a8d..d877ac93afb 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors'; import appEvents from '../../app_events'; import { contextSrv } from 'app/core/services/context_srv'; import { createFolder, getFolderById, searchFolders } from 'app/features/manage-dashboards/state/actions'; -import { PermissionLevelString } from '../../../types'; +import { AccessControlAction, PermissionLevelString } from '../../../types'; export interface Props { onChange: ($folder: { title: string; id: number }) => void; @@ -81,7 +81,12 @@ export class FolderPicker extends PureComponent { const searchHits = await searchFolders(query, permissionLevel); const options: Array> = searchHits.map((hit) => ({ label: hit.title, value: hit.id })); - if (contextSrv.isEditor && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { + + const hasAccess = + contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor) || + contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor); + + if (hasAccess && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { options.unshift({ label: rootName, value: 0 }); } diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index c5607f78931..f35d7f6eb1e 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -200,7 +200,7 @@ class DashNav extends PureComponent { renderRightActionsButton() { const { dashboard, onAddPanel, isFullscreen, kioskMode } = this.props; - const { canEdit, showSettings } = dashboard.meta; + const { canSave, canEdit, showSettings } = dashboard.meta; const { snapshot } = dashboard; const snapshotUrl = snapshot && snapshot.originalUrl; const buttons: ReactNode[] = []; @@ -218,6 +218,9 @@ class DashNav extends PureComponent { if (canEdit && !isFullscreen) { buttons.push(); + } + + if (canSave && !isFullscreen) { buttons.push( {({ showModal, hideModal }) => ( diff --git a/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx new file mode 100644 index 00000000000..b43fdf49642 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { contextSrv } from 'app/core/core'; +import { DashboardModel } from '../../state'; +import { AccessControlAction } from 'app/types'; +import { Permissions } from 'app/core/components/AccessControl'; + +interface Props { + dashboard: DashboardModel; +} + +export const AccessControlDashboardPermissions = ({ dashboard }: Props) => { + const canListUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead); + const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite); + + return ( + + ); +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index 486cacbe7c6..71dd8d9b870 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -9,6 +9,7 @@ import { DashboardModel } from '../../state/DashboardModel'; import { SaveDashboardAsButton, SaveDashboardButton } from '../SaveDashboard/SaveDashboardButton'; import { VariableEditorContainer } from '../../../variables/editor/VariableEditorContainer'; import { DashboardPermissions } from '../DashboardPermissions/DashboardPermissions'; +import { AccessControlDashboardPermissions } from '../DashboardPermissions/AccessControlDashboardPermissions'; import { GeneralSettings } from './GeneralSettings'; import { AnnotationsSettings } from './AnnotationsSettings'; import { LinksSettings } from './LinksSettings'; @@ -16,6 +17,7 @@ import { VersionsSettings } from './VersionsSettings'; import { JsonEditorSettings } from './JsonEditorSettings'; import { GrafanaTheme2, locationUtil } from '@grafana/data'; import { locationService, reportInteraction } from '@grafana/runtime'; +import { AccessControlAction } from 'app/types'; export interface Props { dashboard: DashboardModel; @@ -100,12 +102,21 @@ export function DashboardSettings({ dashboard, editview }: Props) { } if (dashboard.id && dashboard.meta.canAdmin) { - pages.push({ - title: 'Permissions', - id: 'permissions', - icon: 'lock', - component: , - }); + if (!config.featureToggles['accesscontrol']) { + pages.push({ + title: 'Permissions', + id: 'permissions', + icon: 'lock', + component: , + }); + } else if (contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsRead)) { + pages.push({ + title: 'Permissions', + id: 'permissions', + icon: 'lock', + component: , + }); + } } pages.push({ diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index 5a509359a14..9b2d3e77c8f 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -151,7 +151,7 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
- {dashboard.meta.canSave && } + {dashboard.meta.canDelete && }
); diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index 59d0d10e9d1..d6675c9dd97 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -3,19 +3,23 @@ import useAsyncFn from 'react-use/lib/useAsyncFn'; import { locationUtil } from '@grafana/data'; import { SaveDashboardOptions } from './types'; import appEvents from 'app/core/app_events'; +import { contextSrv } from 'app/core/core'; import { useAppNotification } from 'app/core/copy/appNotification'; import { DashboardModel } from 'app/features/dashboard/state'; import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions'; import { locationService, reportInteraction } from '@grafana/runtime'; import { DashboardSavedEvent } from 'app/types/events'; -const saveDashboard = (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => { +const saveDashboard = async (saveModel: any, options: SaveDashboardOptions, dashboard: DashboardModel) => { let folderId = options.folderId; if (folderId === undefined) { folderId = dashboard.meta.folderId ?? saveModel.folderId; } - return saveDashboardApiCall({ ...options, folderId, dashboard: saveModel }); + const result = await saveDashboardApiCall({ ...options, folderId, dashboard: saveModel }); + // fetch updated access control permissions + await contextSrv.fetchUserPermissions(); + return result; }; export const useDashboardSave = (dashboard: DashboardModel) => { diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index e3afab19aea..d088820646d 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -223,7 +223,9 @@ export class DashboardModel implements TimeModel { meta.canSave = meta.canSave !== false; meta.canStar = meta.canStar !== false; meta.canEdit = meta.canEdit !== false; - meta.showSettings = meta.canEdit; + meta.canDelete = meta.canDelete !== false; + + meta.showSettings = meta.canSave; meta.canMakeEditable = meta.canSave && !this.editable; meta.hasUnsavedFolderChange = false; diff --git a/public/app/features/folders/AccessControlFolderPermissions.tsx b/public/app/features/folders/AccessControlFolderPermissions.tsx new file mode 100644 index 00000000000..dac1954caab --- /dev/null +++ b/public/app/features/folders/AccessControlFolderPermissions.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { Permissions } from 'app/core/components/AccessControl'; +import { connect, ConnectedProps } from 'react-redux'; + +import Page from 'app/core/components/Page/Page'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { contextSrv } from 'app/core/core'; +import { getLoadingNav } from './state/navModel'; +import { AccessControlAction, StoreState } from 'app/types'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { getFolderByUid } from './state/actions'; + +interface RouteProps extends GrafanaRouteComponentProps<{ uid: string }> {} + +function mapStateToProps(state: StoreState, props: RouteProps) { + const uid = props.match.params.uid; + return { + uid: uid, + navModel: getNavModel(state.navIndex, `folder-permissions-${uid}`, getLoadingNav(1)), + }; +} + +const mapDispatchToProps = { + getFolderByUid, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type Props = ConnectedProps; + +export const AccessControlFolderPermissions = ({ uid, getFolderByUid, navModel }: Props) => { + useEffect(() => { + getFolderByUid(uid); + }, [getFolderByUid, uid]); + + const canListUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead); + const canSetPermissions = contextSrv.hasPermission(AccessControlAction.FoldersPermissionsWrite); + + return ( + + + + + + ); +}; + +export default connector(AccessControlFolderPermissions); diff --git a/public/app/features/folders/FolderSettingsPage.test.tsx b/public/app/features/folders/FolderSettingsPage.test.tsx index 83080f3b48b..397e8380521 100644 --- a/public/app/features/folders/FolderSettingsPage.test.tsx +++ b/public/app/features/folders/FolderSettingsPage.test.tsx @@ -16,6 +16,7 @@ const setup = (propOverrides?: object) => { uid: '1234', title: 'loading', canSave: true, + canDelete: true, url: 'url', hasChanged: false, version: 1, @@ -52,6 +53,7 @@ describe('Render', () => { uid: '1234', title: 'loading', canSave: true, + canDelete: true, hasChanged: true, version: 1, }, diff --git a/public/app/features/folders/FolderSettingsPage.tsx b/public/app/features/folders/FolderSettingsPage.tsx index 8c738fb1f33..bd3da44ba7e 100644 --- a/public/app/features/folders/FolderSettingsPage.tsx +++ b/public/app/features/folders/FolderSettingsPage.tsx @@ -103,7 +103,7 @@ export class FolderSettingsPage extends PureComponent { - diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts index ebda4e3b1ea..51caf2e98c1 100644 --- a/public/app/features/folders/state/actions.ts +++ b/public/app/features/folders/state/actions.ts @@ -1,5 +1,6 @@ import { locationUtil } from '@grafana/data'; import { getBackendSrv, locationService } from '@grafana/runtime'; +import { contextSrv } from 'app/core/core'; import { backendSrv } from 'app/core/services/backend_srv'; import { FolderState, ThunkResult } from 'app/types'; import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel } from 'app/types/acl'; @@ -143,6 +144,7 @@ export function addFolderPermission(newItem: NewDashboardAclItem): ThunkResult { return async (dispatch) => { const newFolder = await getBackendSrv().post('/api/folders', { title: folderName }); + await contextSrv.fetchUserPermissions(); dispatch(notifyApp(createSuccessNotification('Folder Created', 'OK'))); locationService.push(locationUtil.stripBaseFromUrl(newFolder.url)); }; diff --git a/public/app/features/folders/state/navModel.ts b/public/app/features/folders/state/navModel.ts index 392ad081d18..1ca2fad5f90 100644 --- a/public/app/features/folders/state/navModel.ts +++ b/public/app/features/folders/state/navModel.ts @@ -61,6 +61,7 @@ export function getLoadingNav(tabIndex: number): NavModel { canSave: true, canEdit: true, canAdmin: true, + canDelete: true, version: 0, }); diff --git a/public/app/features/folders/state/reducers.test.ts b/public/app/features/folders/state/reducers.test.ts index 65b9c944574..041efafe131 100644 --- a/public/app/features/folders/state/reducers.test.ts +++ b/public/app/features/folders/state/reducers.test.ts @@ -18,6 +18,7 @@ function getTestFolder(): FolderDTO { canSave: true, canEdit: true, canAdmin: true, + canDelete: true, version: 0, }; } diff --git a/public/app/features/folders/state/reducers.ts b/public/app/features/folders/state/reducers.ts index c0d7e28e109..28fe7e10811 100644 --- a/public/app/features/folders/state/reducers.ts +++ b/public/app/features/folders/state/reducers.ts @@ -9,6 +9,7 @@ export const initialState: FolderState = { title: 'loading', url: '', canSave: false, + canDelete: false, hasChanged: false, version: 1, permissions: [], diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 017739d0ce2..28fb00dbef7 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -124,9 +124,15 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/dashboards/f/:uid/:slug/permissions', - component: SafeDynamicImport( - () => import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions') - ), + component: + config.featureToggles['accesscontrol'] && contextSrv.hasPermission(AccessControlAction.FoldersPermissionsRead) + ? SafeDynamicImport( + () => + import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/AccessControlFolderPermissions') + ) + : SafeDynamicImport( + () => import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions') + ), }, { path: '/dashboards/f/:uid/:slug/settings', diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index ca4fb253656..7c90bce0968 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -66,6 +66,20 @@ export enum AccessControlAction { ActionTeamsRolesAdd = 'teams.roles:add', ActionTeamsRolesRemove = 'teams.roles:remove', ActionUserRolesList = 'users.roles:list', + + DashboardsRead = 'dashboards:read', + DashboardsWrite = 'dashboards:write', + DashboardsDelete = 'dashboards:delete', + DashboardsCreate = 'dashboards:create', + DashboardsPermissionsRead = 'dashboards.permissions:read', + DashboardsPermissionsWrite = 'dashboards.permissions:read', + + FoldersRead = 'folders:read', + FoldersWrite = 'folders:read', + FoldersDelete = 'folders:delete', + FoldersCreate = 'folders:create', + FoldersPermissionsRead = 'folders.permissions:read', + FoldersPermissionsWrite = 'folders.permissions:read', } export interface Role { diff --git a/public/app/types/folders.ts b/public/app/types/folders.ts index 1e90fc3c750..9577b4a6597 100644 --- a/public/app/types/folders.ts +++ b/public/app/types/folders.ts @@ -9,6 +9,7 @@ export interface FolderDTO { canSave: boolean; canEdit: boolean; canAdmin: boolean; + canDelete: boolean; } export interface FolderState { @@ -17,6 +18,7 @@ export interface FolderState { title: string; url: string; canSave: boolean; + canDelete: boolean; hasChanged: boolean; version: number; permissions: DashboardAcl[];