Skip to content

Commit

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

This PR contains fixe for
#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.

## After: 
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

### User with read access but not crud access
- 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)

### User with full access

![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)

### Checklist

- [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 <[email protected]>
  • Loading branch information
christineweng and kibanamachine authored Jan 3, 2023
1 parent ed840b5 commit 3abf705
Show file tree
Hide file tree
Showing 7 changed files with 188 additions and 70 deletions.
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 @@ -86,6 +87,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 @@ -96,6 +100,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
Original file line number Diff line number Diff line change
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 @@ -412,6 +451,9 @@ describe('OpenTimeline', () => {
});

test('it should include createRule in timeline actions if onCreateRule is passed', () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: true, read: true },
});
const defaultProps = {
...getDefaultTestProps(mockResults),
timelineStatus: TimelineStatus.active,
Expand All @@ -427,6 +469,25 @@ describe('OpenTimeline', () => {
).toEqual(['createFrom', 'duplicate', 'createRule', '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 @@ -456,6 +517,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 @@ -500,6 +564,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 @@ -515,6 +582,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
Original file line number Diff line number Diff line change
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 @@ -78,8 +78,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 @@ -149,28 +150,41 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
}, [setImportDataModalToggle, refetch]);

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

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;
}, [onCreateRule, timelineStatus, onDeleteSelected, deleteTimelines]);
return timelineActions;
}
// user with read access should only see export
if (timelineStatus !== TimelineStatus.immutable) {
return ['export', 'selectable'];
}
return [];
}, [
onCreateRule,
timelineStatus,
onDeleteSelected,
deleteTimelines,
kibanaSecuritySolutionsPrivileges,
]);

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ export const getActionsColumns = ({
onOpenDeleteTimelineModal,
onOpenTimeline,
onCreateRule,
hasCrudAccess,
}: {
actionTimelineToShow: ActionTimelineToShow[];
deleteTimelines?: DeleteTimelines;
enableExportTimelineDownloader?: EnableExportTimelineDownloader;
onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal;
onOpenTimeline: OnOpenTimeline;
onCreateRule?: OnCreateRuleFromTimeline;
hasCrudAccess: boolean;
}): [TimelineActionsOverflowColumns] => {
const createTimelineFromTemplate = {
name: i18n.CREATE_TIMELINE_FROM_TEMPLATE,
Expand Down Expand Up @@ -149,10 +151,9 @@ export const getActionsColumns = ({
'data-test-subj': 'create-rule-from-timeline',
available: () => actionTimelineToShow.includes('createRule') && onCreateRule != null,
};

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

0 comments on commit 3abf705

Please sign in to comment.