mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 05:51:51 +08:00
Access control: Dashboard and folder permissions frontend (#45094)
This commit is contained in:
@ -269,7 +269,7 @@ exports[`no enzyme tests`] = {
|
|||||||
"public/app/features/explore/TimeSyncButton.test.tsx:4230066214": [
|
"public/app/features/explore/TimeSyncButton.test.tsx:4230066214": [
|
||||||
[2, 17, 13, "RegExp match", "2409514259"]
|
[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"]
|
[2, 19, 13, "RegExp match", "2409514259"]
|
||||||
],
|
],
|
||||||
"public/app/features/invites/InviteesTable.test.tsx:3077684439": [
|
"public/app/features/invites/InviteesTable.test.tsx:3077684439": [
|
||||||
|
@ -7,7 +7,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
|||||||
import appEvents from '../../app_events';
|
import appEvents from '../../app_events';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { createFolder, getFolderById, searchFolders } from 'app/features/manage-dashboards/state/actions';
|
import { createFolder, getFolderById, searchFolders } from 'app/features/manage-dashboards/state/actions';
|
||||||
import { PermissionLevelString } from '../../../types';
|
import { AccessControlAction, PermissionLevelString } from '../../../types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange: ($folder: { title: string; id: number }) => void;
|
onChange: ($folder: { title: string; id: number }) => void;
|
||||||
@ -81,7 +81,12 @@ export class FolderPicker extends PureComponent<Props, State> {
|
|||||||
const searchHits = await searchFolders(query, permissionLevel);
|
const searchHits = await searchFolders(query, permissionLevel);
|
||||||
|
|
||||||
const options: Array<SelectableValue<number>> = searchHits.map((hit) => ({ label: hit.title, value: hit.id }));
|
const options: Array<SelectableValue<number>> = 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 });
|
options.unshift({ label: rootName, value: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -200,7 +200,7 @@ class DashNav extends PureComponent<Props> {
|
|||||||
|
|
||||||
renderRightActionsButton() {
|
renderRightActionsButton() {
|
||||||
const { dashboard, onAddPanel, isFullscreen, kioskMode } = this.props;
|
const { dashboard, onAddPanel, isFullscreen, kioskMode } = this.props;
|
||||||
const { canEdit, showSettings } = dashboard.meta;
|
const { canSave, canEdit, showSettings } = dashboard.meta;
|
||||||
const { snapshot } = dashboard;
|
const { snapshot } = dashboard;
|
||||||
const snapshotUrl = snapshot && snapshot.originalUrl;
|
const snapshotUrl = snapshot && snapshot.originalUrl;
|
||||||
const buttons: ReactNode[] = [];
|
const buttons: ReactNode[] = [];
|
||||||
@ -218,6 +218,9 @@ class DashNav extends PureComponent<Props> {
|
|||||||
|
|
||||||
if (canEdit && !isFullscreen) {
|
if (canEdit && !isFullscreen) {
|
||||||
buttons.push(<ToolbarButton tooltip="Add panel" icon="panel-add" onClick={onAddPanel} key="button-panel-add" />);
|
buttons.push(<ToolbarButton tooltip="Add panel" icon="panel-add" onClick={onAddPanel} key="button-panel-add" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canSave && !isFullscreen) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<ModalsController key="button-save">
|
<ModalsController key="button-save">
|
||||||
{({ showModal, hideModal }) => (
|
{({ showModal, hideModal }) => (
|
||||||
|
@ -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 (
|
||||||
|
<Permissions
|
||||||
|
resource={'dashboards'}
|
||||||
|
resourceId={dashboard.uid}
|
||||||
|
canListUsers={canListUsers}
|
||||||
|
canSetPermissions={canSetPermissions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -9,6 +9,7 @@ import { DashboardModel } from '../../state/DashboardModel';
|
|||||||
import { SaveDashboardAsButton, SaveDashboardButton } from '../SaveDashboard/SaveDashboardButton';
|
import { SaveDashboardAsButton, SaveDashboardButton } from '../SaveDashboard/SaveDashboardButton';
|
||||||
import { VariableEditorContainer } from '../../../variables/editor/VariableEditorContainer';
|
import { VariableEditorContainer } from '../../../variables/editor/VariableEditorContainer';
|
||||||
import { DashboardPermissions } from '../DashboardPermissions/DashboardPermissions';
|
import { DashboardPermissions } from '../DashboardPermissions/DashboardPermissions';
|
||||||
|
import { AccessControlDashboardPermissions } from '../DashboardPermissions/AccessControlDashboardPermissions';
|
||||||
import { GeneralSettings } from './GeneralSettings';
|
import { GeneralSettings } from './GeneralSettings';
|
||||||
import { AnnotationsSettings } from './AnnotationsSettings';
|
import { AnnotationsSettings } from './AnnotationsSettings';
|
||||||
import { LinksSettings } from './LinksSettings';
|
import { LinksSettings } from './LinksSettings';
|
||||||
@ -16,6 +17,7 @@ import { VersionsSettings } from './VersionsSettings';
|
|||||||
import { JsonEditorSettings } from './JsonEditorSettings';
|
import { JsonEditorSettings } from './JsonEditorSettings';
|
||||||
import { GrafanaTheme2, locationUtil } from '@grafana/data';
|
import { GrafanaTheme2, locationUtil } from '@grafana/data';
|
||||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
@ -100,12 +102,21 @@ export function DashboardSettings({ dashboard, editview }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dashboard.id && dashboard.meta.canAdmin) {
|
if (dashboard.id && dashboard.meta.canAdmin) {
|
||||||
pages.push({
|
if (!config.featureToggles['accesscontrol']) {
|
||||||
title: 'Permissions',
|
pages.push({
|
||||||
id: 'permissions',
|
title: 'Permissions',
|
||||||
icon: 'lock',
|
id: 'permissions',
|
||||||
component: <DashboardPermissions dashboard={dashboard} />,
|
icon: 'lock',
|
||||||
});
|
component: <DashboardPermissions dashboard={dashboard} />,
|
||||||
|
});
|
||||||
|
} else if (contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsRead)) {
|
||||||
|
pages.push({
|
||||||
|
title: 'Permissions',
|
||||||
|
id: 'permissions',
|
||||||
|
icon: 'lock',
|
||||||
|
component: <AccessControlDashboardPermissions dashboard={dashboard} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
|
@ -151,7 +151,7 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
|
|||||||
</CollapsableSection>
|
</CollapsableSection>
|
||||||
|
|
||||||
<div className="gf-form-button-row">
|
<div className="gf-form-button-row">
|
||||||
{dashboard.meta.canSave && <DeleteDashboardButton dashboard={dashboard} />}
|
{dashboard.meta.canDelete && <DeleteDashboardButton dashboard={dashboard} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -3,19 +3,23 @@ import useAsyncFn from 'react-use/lib/useAsyncFn';
|
|||||||
import { locationUtil } from '@grafana/data';
|
import { locationUtil } from '@grafana/data';
|
||||||
import { SaveDashboardOptions } from './types';
|
import { SaveDashboardOptions } from './types';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
import { DashboardModel } from 'app/features/dashboard/state';
|
||||||
import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions';
|
import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions';
|
||||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||||
import { DashboardSavedEvent } from 'app/types/events';
|
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;
|
let folderId = options.folderId;
|
||||||
if (folderId === undefined) {
|
if (folderId === undefined) {
|
||||||
folderId = dashboard.meta.folderId ?? saveModel.folderId;
|
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) => {
|
export const useDashboardSave = (dashboard: DashboardModel) => {
|
||||||
|
@ -223,7 +223,9 @@ export class DashboardModel implements TimeModel {
|
|||||||
meta.canSave = meta.canSave !== false;
|
meta.canSave = meta.canSave !== false;
|
||||||
meta.canStar = meta.canStar !== false;
|
meta.canStar = meta.canStar !== false;
|
||||||
meta.canEdit = meta.canEdit !== 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.canMakeEditable = meta.canSave && !this.editable;
|
||||||
meta.hasUnsavedFolderChange = false;
|
meta.hasUnsavedFolderChange = false;
|
||||||
|
|
||||||
|
@ -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<typeof connector>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Page navModel={navModel}>
|
||||||
|
<Page.Contents>
|
||||||
|
<Permissions
|
||||||
|
resource="folders"
|
||||||
|
resourceId={uid}
|
||||||
|
canListUsers={canListUsers}
|
||||||
|
canSetPermissions={canSetPermissions}
|
||||||
|
/>
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connector(AccessControlFolderPermissions);
|
@ -16,6 +16,7 @@ const setup = (propOverrides?: object) => {
|
|||||||
uid: '1234',
|
uid: '1234',
|
||||||
title: 'loading',
|
title: 'loading',
|
||||||
canSave: true,
|
canSave: true,
|
||||||
|
canDelete: true,
|
||||||
url: 'url',
|
url: 'url',
|
||||||
hasChanged: false,
|
hasChanged: false,
|
||||||
version: 1,
|
version: 1,
|
||||||
@ -52,6 +53,7 @@ describe('Render', () => {
|
|||||||
uid: '1234',
|
uid: '1234',
|
||||||
title: 'loading',
|
title: 'loading',
|
||||||
canSave: true,
|
canSave: true,
|
||||||
|
canDelete: true,
|
||||||
hasChanged: true,
|
hasChanged: true,
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
|
@ -103,7 +103,7 @@ export class FolderSettingsPage extends PureComponent<Props, State> {
|
|||||||
<Button type="submit" disabled={!folder.canSave || !folder.hasChanged}>
|
<Button type="submit" disabled={!folder.canSave || !folder.hasChanged}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={this.onDelete} disabled={!folder.canSave}>
|
<Button variant="destructive" onClick={this.onDelete} disabled={!folder.canDelete}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { locationUtil } from '@grafana/data';
|
import { locationUtil } from '@grafana/data';
|
||||||
import { getBackendSrv, locationService } from '@grafana/runtime';
|
import { getBackendSrv, locationService } from '@grafana/runtime';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
import { FolderState, ThunkResult } from 'app/types';
|
import { FolderState, ThunkResult } from 'app/types';
|
||||||
import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel } from 'app/types/acl';
|
import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel } from 'app/types/acl';
|
||||||
@ -143,6 +144,7 @@ export function addFolderPermission(newItem: NewDashboardAclItem): ThunkResult<v
|
|||||||
export function createNewFolder(folderName: string): ThunkResult<void> {
|
export function createNewFolder(folderName: string): ThunkResult<void> {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
const newFolder = await getBackendSrv().post('/api/folders', { title: folderName });
|
const newFolder = await getBackendSrv().post('/api/folders', { title: folderName });
|
||||||
|
await contextSrv.fetchUserPermissions();
|
||||||
dispatch(notifyApp(createSuccessNotification('Folder Created', 'OK')));
|
dispatch(notifyApp(createSuccessNotification('Folder Created', 'OK')));
|
||||||
locationService.push(locationUtil.stripBaseFromUrl(newFolder.url));
|
locationService.push(locationUtil.stripBaseFromUrl(newFolder.url));
|
||||||
};
|
};
|
||||||
|
@ -61,6 +61,7 @@ export function getLoadingNav(tabIndex: number): NavModel {
|
|||||||
canSave: true,
|
canSave: true,
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
canAdmin: true,
|
canAdmin: true,
|
||||||
|
canDelete: true,
|
||||||
version: 0,
|
version: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ function getTestFolder(): FolderDTO {
|
|||||||
canSave: true,
|
canSave: true,
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
canAdmin: true,
|
canAdmin: true,
|
||||||
|
canDelete: true,
|
||||||
version: 0,
|
version: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ export const initialState: FolderState = {
|
|||||||
title: 'loading',
|
title: 'loading',
|
||||||
url: '',
|
url: '',
|
||||||
canSave: false,
|
canSave: false,
|
||||||
|
canDelete: false,
|
||||||
hasChanged: false,
|
hasChanged: false,
|
||||||
version: 1,
|
version: 1,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
@ -124,9 +124,15 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/dashboards/f/:uid/:slug/permissions',
|
path: '/dashboards/f/:uid/:slug/permissions',
|
||||||
component: SafeDynamicImport(
|
component:
|
||||||
() => import(/* webpackChunkName: "FolderPermissions"*/ 'app/features/folders/FolderPermissions')
|
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',
|
path: '/dashboards/f/:uid/:slug/settings',
|
||||||
|
@ -66,6 +66,20 @@ export enum AccessControlAction {
|
|||||||
ActionTeamsRolesAdd = 'teams.roles:add',
|
ActionTeamsRolesAdd = 'teams.roles:add',
|
||||||
ActionTeamsRolesRemove = 'teams.roles:remove',
|
ActionTeamsRolesRemove = 'teams.roles:remove',
|
||||||
ActionUserRolesList = 'users.roles:list',
|
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 {
|
export interface Role {
|
||||||
|
@ -9,6 +9,7 @@ export interface FolderDTO {
|
|||||||
canSave: boolean;
|
canSave: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FolderState {
|
export interface FolderState {
|
||||||
@ -17,6 +18,7 @@ export interface FolderState {
|
|||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
canSave: boolean;
|
canSave: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
hasChanged: boolean;
|
hasChanged: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
permissions: DashboardAcl[];
|
permissions: DashboardAcl[];
|
||||||
|
Reference in New Issue
Block a user