Skip to content

Commit

Permalink
[Security Solution][Bug] Add privilege check in open timeline (elasti…
Browse files Browse the repository at this point in the history
…c#147964)

This PR contains fixe for
elastic#147544. On Timelines page, a
Kibana read-only user was able to see and click on options to create and
duplicate timelines. This PR fixes this bug by checking user privilege
(have crud access) before showing timeline actions.

User with read only access to kibana security solutions can:
1) select timelines
2) export timelines
3) export timelines in bulk

User with crud access to kibana security solutions can:
1) select timelines
2) have the options to modify timelines as before
3) bulk actions include delete timelines and export timelines
4) see and click on 'import', ' Create new timeline', 'Create new
timeline template' buttons

- Have access to export ('Export selected'), cannot see 'Create new
timeline' buttons

![image](https://user-images.githubusercontent.com/18648970/209210913-0554bc4c-5c8e-45ae-8e27-54a7e33e3f8e.png)

- 'Export selected' in bulk actions

![image](https://user-images.githubusercontent.com/18648970/209210992-f102d8d4-479f-4d0a-84c2-125cc754c5ce.png)

![image](https://user-images.githubusercontent.com/18648970/209021998-fbe0b63d-8dfd-4098-9774-7423899a45e1.png)

![image](https://user-images.githubusercontent.com/18648970/209209755-b5e5ce2b-0af9-42c6-b1cc-64a2675bf19d.png)

- 'Export selected' and 'Delete selected' available in bulk actions
dropdown

![image](https://user-images.githubusercontent.com/18648970/210408773-0fc5b100-0f57-4526-9c8f-0aba1f1d0e76.png)

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
(cherry picked from commit 3abf705)
  • Loading branch information
christineweng committed Jan 9, 2023
1 parent f1a50f7 commit aaa57eb
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 64 deletions.
Expand Up @@ -71,6 +71,33 @@ export const useEditTimelineBatchActions = ({
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => {
const disabled = selectedItems == null || selectedItems.length === 0;
const items = [];
if (selectedItems) {
items.push(
<EuiContextMenuItem
data-test-subj="export-timeline-action"
disabled={disabled}
icon="exportAction"
key="ExportItemKey"
onClick={handleEnableExportTimelineDownloader}
>
{i18n.EXPORT_SELECTED}
</EuiContextMenuItem>
);
}
if (deleteTimelines) {
items.push(
<EuiContextMenuItem
data-test-subj="delete-timeline-action"
disabled={disabled}
icon="trash"
key="DeleteItemKey"
onClick={handleOnOpenDeleteTimelineModal}
>
{i18n.DELETE_SELECTED}
</EuiContextMenuItem>
);
}
return (
<>
<EditTimelineActions
Expand All @@ -87,29 +114,7 @@ export const useEditTimelineBatchActions = ({
: selectedItems[0]?.title ?? ''
}
/>

<EuiContextMenuPanel
items={[
<EuiContextMenuItem
data-test-subj="export-timeline-action"
disabled={disabled}
icon="exportAction"
key="ExportItemKey"
onClick={handleEnableExportTimelineDownloader}
>
{i18n.EXPORT_SELECTED}
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="delete-timeline-action"
disabled={disabled}
icon="trash"
key="DeleteItemKey"
onClick={handleOnOpenDeleteTimelineModal}
>
{i18n.DELETE_SELECTED}
</EuiContextMenuItem>,
]}
/>
<EuiContextMenuPanel items={items} />
</>
);
},
Expand Down
Expand Up @@ -29,6 +29,7 @@ import { TimelineTabsStyle } from './types';
import type { UseTimelineTypesArgs, UseTimelineTypesResult } from './use_timeline_types';
import { useTimelineTypes } from './use_timeline_types';
import { deleteTimelinesByIds } from '../../containers/api';
import { useUserPrivileges } from '../../../common/components/user_privileges';

jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
Expand Down Expand Up @@ -78,6 +79,9 @@ jest.mock('../../containers/api', () => ({
deleteTimelinesByIds: jest.fn(),
}));

jest.mock('../../../common/components/user_privileges');
const useUserPrivilegesMock = useUserPrivileges as jest.Mock;

describe('StatefulOpenTimeline', () => {
const title = 'All Timelines / Open Timelines';
let mockHistory: History[];
Expand All @@ -88,6 +92,9 @@ describe('StatefulOpenTimeline', () => {
tabName: TimelineType.default,
pageName: SecurityPageName.timelines,
});
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
mockHistory = [];
(useHistory as jest.Mock).mockReturnValue(mockHistory);
(useGetAllTimeline as unknown as jest.Mock).mockReturnValue({
Expand Down
Expand Up @@ -20,6 +20,7 @@ import { OpenTimeline } from './open_timeline';
import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from './constants';
import { TimelineType, TimelineStatus } from '../../../../common/types/timeline';
import { getMockTheme } from '../../../common/lib/kibana/kibana_react.mock';
import { useUserPrivileges } from '../../../common/components/user_privileges';

jest.mock('../../../common/lib/kibana');

Expand All @@ -42,6 +43,9 @@ const mockTheme = getMockTheme({
},
});

jest.mock('../../../common/components/user_privileges');
const useUserPrivilegesMock = useUserPrivileges as jest.Mock;

describe('OpenTimeline', () => {
const title = 'All Timelines / Open Timelines';

Expand Down Expand Up @@ -102,6 +106,9 @@ describe('OpenTimeline', () => {
});

test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
const defaultProps = getDefaultTestProps(mockResults);
const wrapper = mountWithIntl(
<ThemeProvider theme={mockTheme}>
Expand Down Expand Up @@ -177,6 +184,29 @@ describe('OpenTimeline', () => {
expect(props.actionTimelineToShow).not.toContain('delete');
});

test('it does NOT show the delete action when user has read only access', () => {
const defaultProps = {
...getDefaultTestProps(mockResults),
onDeleteSelected: undefined,
deleteTimelines: undefined,
};
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: false, read: true },
});
const wrapper = mountWithIntl(
<ThemeProvider theme={mockTheme}>
<OpenTimeline {...defaultProps} />
</ThemeProvider>
);

const props = wrapper
.find('[data-test-subj="timelines-table"]')
.first()
.props() as TimelinesTableProps;

expect(props.actionTimelineToShow).not.toContain('delete');
});

test('it renders an empty string when the query is an empty string', () => {
const defaultProps = {
...getDefaultTestProps(mockResults),
Expand Down Expand Up @@ -324,6 +354,9 @@ describe('OpenTimeline', () => {
});

test('it should disable delete timeline if no timeline is selected', async () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: null,
Expand Down Expand Up @@ -372,6 +405,9 @@ describe('OpenTimeline', () => {
});

test('it should enable delete timeline if a timeline is selected', async () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: null,
Expand All @@ -396,6 +432,9 @@ describe('OpenTimeline', () => {
});

test("it should render a selectable timeline table if timelineStatus is active (selecting custom templates' tab)", () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: TimelineStatus.active,
Expand All @@ -411,6 +450,25 @@ describe('OpenTimeline', () => {
).toEqual(['createFrom', 'duplicate', 'export', 'selectable', 'delete']);
});

test('it should NOT include createFrom, duplicate, createRule, delete in timeline actions when user has read only access', () => {
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: TimelineStatus.active,
};
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: false, read: true },
});
const wrapper = mountWithIntl(
<ThemeProvider theme={mockTheme}>
<OpenTimeline {...defaultProps} onCreateRule={jest.fn()} />
</ThemeProvider>
);

expect(
wrapper.find('[data-test-subj="timelines-table"]').first().prop('actionTimelineToShow')
).toEqual(['export', 'selectable']);
});

test("it should render selected count if timelineStatus is active (selecting custom templates' tab)", () => {
const defaultProps = {
...getDefaultTestProps(mockResults),
Expand Down Expand Up @@ -440,6 +498,9 @@ describe('OpenTimeline', () => {
});

test("it should not render a selectable timeline table if timelineStatus is immutable (selecting Elastic templates' tab)", () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: TimelineStatus.immutable,
Expand Down Expand Up @@ -484,6 +545,9 @@ describe('OpenTimeline', () => {
});

test("it should render a selectable timeline table if timelineStatus is null (no template timelines' tab selected)", () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: null,
Expand All @@ -499,6 +563,25 @@ describe('OpenTimeline', () => {
).toEqual(['createFrom', 'duplicate', 'export', 'selectable', 'delete']);
});

test("it should render a selectable timeline table if timelineStatus is null (no template timelines' tab selected) and user has read only access", () => {
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: null,
};
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: false, read: true },
});
const wrapper = mountWithIntl(
<ThemeProvider theme={mockTheme}>
<OpenTimeline {...defaultProps} />
</ThemeProvider>
);

expect(
wrapper.find('[data-test-subj="timelines-table"]').first().prop('actionTimelineToShow')
).toEqual(['export', 'selectable']);
});

test("it should render selected count if timelineStatus is null (no template timelines' tab selected)", () => {
const defaultProps = {
...getDefaultTestProps(mockResults),
Expand Down
Expand Up @@ -21,7 +21,7 @@ import {
} from '../../../common/components/utility_bar';

import { importTimelines } from '../../containers/api';

import { useUserPrivileges } from '../../../common/components/user_privileges';
import { useEditTimelineBatchActions } from './edit_timeline_batch_actions';
import { useEditTimelineActions } from './edit_timeline_actions';
import { EditTimelineActions } from './export_timeline';
Expand Down Expand Up @@ -77,8 +77,9 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
onCompleteEditTimelineAction,
} = useEditTimelineActions();

const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges();
const { getBatchItemsPopoverContent } = useEditTimelineBatchActions({
deleteTimelines,
deleteTimelines: kibanaSecuritySolutionsPrivileges.crud ? deleteTimelines : undefined,
selectedItems,
tableRef,
timelineType,
Expand Down Expand Up @@ -148,23 +149,35 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
}, [setImportDataModalToggle, refetch]);

const actionTimelineToShow = useMemo<ActionTimelineToShow[]>(() => {
const timelineActions: ActionTimelineToShow[] = ['createFrom', 'duplicate'];
if (kibanaSecuritySolutionsPrivileges.crud) {
const timelineActions: ActionTimelineToShow[] = ['createFrom', 'duplicate'];

if (timelineStatus !== TimelineStatus.immutable) {
timelineActions.push('export');
timelineActions.push('selectable');
}
if (timelineStatus !== TimelineStatus.immutable) {
timelineActions.push('export');
timelineActions.push('selectable');
}

if (
onDeleteSelected != null &&
deleteTimelines != null &&
timelineStatus !== TimelineStatus.immutable
) {
timelineActions.push('delete');
}
if (
onDeleteSelected != null &&
deleteTimelines != null &&
timelineStatus !== TimelineStatus.immutable
) {
timelineActions.push('delete');
}

return timelineActions;
}, [onDeleteSelected, deleteTimelines, timelineStatus]);
return timelineActions;
}
// user with read access should only see export
if (timelineStatus !== TimelineStatus.immutable) {
return ['export', 'selectable'];
}
return [];
}, [
timelineStatus,
onDeleteSelected,
deleteTimelines,
kibanaSecuritySolutionsPrivileges,
]);

const SearchRowContent = useMemo(() => <>{templateTimelineFilter}</>, [templateTimelineFilter]);

Expand Down
Expand Up @@ -26,12 +26,14 @@ export const getActionsColumns = ({
enableExportTimelineDownloader,
onOpenDeleteTimelineModal,
onOpenTimeline,
hasCrudAccess,
}: {
actionTimelineToShow: ActionTimelineToShow[];
deleteTimelines?: DeleteTimelines;
enableExportTimelineDownloader?: EnableExportTimelineDownloader;
onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal;
onOpenTimeline: OnOpenTimeline;
hasCrudAccess: boolean;
}): [TimelineActionsOverflowColumns] => {
const createTimelineFromTemplate = {
name: i18n.CREATE_TIMELINE_FROM_TEMPLATE,
Expand Down Expand Up @@ -134,7 +136,7 @@ export const getActionsColumns = ({

return [
{
width: '80px',
width: hasCrudAccess ? '80px' : '150px',
actions: [
createTimelineFromTemplate,
createTemplateFromTimeline,
Expand Down

0 comments on commit aaa57eb

Please sign in to comment.