[Security Solution] [Detections] Disable edit button when user does not have actions privileges w/ rule + actions (#80220)

* disable edit button only when there is an action present on the rule to be edited, but the user attempting the edit does not have actions privileges

* adds tooltip to explain why the edit rule button is disabled

* prevent user from editing rules with actions on the all rules table

* adds tooltip to appear on all rules table

* updates tests for missing params and missing mock of useKibana

* disable activate switch on all rules table and rule details page

* remove as casting in favor of a boolean type guard to ensure actions.show capabilities are a boolean even though tye are typed as a boolean | Record

* disable duplicate rule functionality for rules with actions

* fix positioning of tooltips and add tooltip to rule duplicate button in overflow button

* update tests

* WIP - display bulk actions dropdown options as disabled + add tooltips describing why they are disabled

* add eui tool tip as child of of each context menu item

* PR feedback and utilize map of rule ids to rules to replace usage of array.finds

* update snapshot

* fix mocks

* fix mocks

* update wording with feedback from design team

Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
This commit is contained in:
Devin W. Hurley 2020-10-19 15:45:32 -04:00 committed by GitHub
parent 4c81b1a64b
commit 2f01a0911c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 368 additions and 75 deletions

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Rule } from '../../../detections/containers/detection_engine/rules';
import * as i18n from '../../../detections/pages/detection_engine/rules/translations';
import { isMlRule } from '../../../../common/machine_learning/helpers';
import * as detectionI18n from '../../../detections/pages/detection_engine/translations';
export const isBoolean = (obj: unknown): obj is boolean => typeof obj === 'boolean';
export const canEditRuleWithActions = (
rule: Rule | null | undefined,
privileges:
| boolean
| Readonly<{
[x: string]: boolean;
}>
): boolean => {
if (rule == null) {
return true;
}
if (rule.actions?.length > 0 && isBoolean(privileges)) {
return privileges;
}
return true;
};
export const getToolTipContent = (
rule: Rule | null | undefined,
hasMlPermissions: boolean,
hasReadActionsPrivileges:
| boolean
| Readonly<{
[x: string]: boolean;
}>
): string | undefined => {
if (rule == null) {
return undefined;
} else if (isMlRule(rule.type) && !hasMlPermissions) {
return detectionI18n.ML_RULES_DISABLED_MESSAGE;
} else if (!canEditRuleWithActions(rule, hasReadActionsPrivileges)) {
return i18n.EDIT_RULE_SETTINGS_TOOLTIP;
} else {
return undefined;
}
};

View file

@ -40,7 +40,14 @@ exports[`RuleActionsOverflow snapshots renders correctly against snapshot 1`] =
icon="copy"
onClick={[Function]}
>
Duplicate rule
<EuiToolTip
delay="regular"
position="left"
>
<React.Fragment>
Duplicate rule
</React.Fragment>
</EuiToolTip>
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="rules-details-export-rule"

View file

@ -29,7 +29,11 @@ describe('RuleActionsOverflow', () => {
describe('snapshots', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
expect(wrapper).toMatchSnapshot();
});
@ -38,7 +42,11 @@ describe('RuleActionsOverflow', () => {
describe('rules details menu panel', () => {
test('there is at least one item when there is a rule within the rules-details-menu-panel', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -51,7 +59,13 @@ describe('RuleActionsOverflow', () => {
});
test('items are empty when there is a null rule within the rules-details-menu-panel', () => {
const wrapper = mount(<RuleActionsOverflow rule={null} userHasNoPermissions={false} />);
const wrapper = mount(
<RuleActionsOverflow
rule={null}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
expect(
@ -60,7 +74,13 @@ describe('RuleActionsOverflow', () => {
});
test('items are empty when there is an undefined rule within the rules-details-menu-panel', () => {
const wrapper = mount(<RuleActionsOverflow rule={null} userHasNoPermissions={false} />);
const wrapper = mount(
<RuleActionsOverflow
rule={null}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
expect(
@ -70,7 +90,11 @@ describe('RuleActionsOverflow', () => {
test('it opens the popover when rules-details-popover-button-icon is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -83,7 +107,11 @@ describe('RuleActionsOverflow', () => {
describe('rules details pop over button icon', () => {
test('it does not open the popover when rules-details-popover-button-icon is clicked when the user does not have permission', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={true} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={true}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -96,7 +124,13 @@ describe('RuleActionsOverflow', () => {
describe('rules details duplicate rule', () => {
test('it does not open the popover when rules-details-popover-button-icon is clicked and the user does not have permission', () => {
const rule = mockRule('id');
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={true} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={true}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
expect(wrapper.find('[data-test-subj="rules-details-delete-rule"] button').exists()).toEqual(
@ -106,7 +140,11 @@ describe('RuleActionsOverflow', () => {
test('it opens the popover when rules-details-popover-button-icon is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -117,7 +155,11 @@ describe('RuleActionsOverflow', () => {
test('it closes the popover when rules-details-duplicate-rule is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -130,7 +172,11 @@ describe('RuleActionsOverflow', () => {
test('it calls duplicateRulesAction when rules-details-duplicate-rule is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -141,7 +187,13 @@ describe('RuleActionsOverflow', () => {
test('it calls duplicateRulesAction with the rule and rule.id when rules-details-duplicate-rule is clicked', () => {
const rule = mockRule('id');
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={false} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
wrapper.find('[data-test-subj="rules-details-duplicate-rule"] button').simulate('click');
@ -158,7 +210,13 @@ describe('RuleActionsOverflow', () => {
describe('rules details export rule', () => {
test('it does not open the popover when rules-details-popover-button-icon is clicked and the user does not have permission', () => {
const rule = mockRule('id');
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={true} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={true}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
expect(wrapper.find('[data-test-subj="rules-details-export-rule"] button').exists()).toEqual(
@ -168,7 +226,11 @@ describe('RuleActionsOverflow', () => {
test('it closes the popover when rules-details-export-rule is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -181,7 +243,13 @@ describe('RuleActionsOverflow', () => {
test('it sets the rule.rule_id on the generic downloader when rules-details-export-rule is clicked', () => {
const rule = mockRule('id');
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={false} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
wrapper.find('[data-test-subj="rules-details-export-rule"] button').simulate('click');
@ -194,7 +262,13 @@ describe('RuleActionsOverflow', () => {
test('it does not close the pop over on rules-details-export-rule when the rule is an immutable rule and the user does a click', () => {
const rule = mockRule('id');
rule.immutable = true;
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={false} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
wrapper.find('[data-test-subj="rules-details-export-rule"] button').simulate('click');
@ -207,7 +281,13 @@ describe('RuleActionsOverflow', () => {
test('it does not set the rule.rule_id on rules-details-export-rule when the rule is an immutable rule', () => {
const rule = mockRule('id');
rule.immutable = true;
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={false} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
wrapper.find('[data-test-subj="rules-details-export-rule"] button').simulate('click');
@ -221,7 +301,13 @@ describe('RuleActionsOverflow', () => {
describe('rules details delete rule', () => {
test('it does not open the popover when rules-details-popover-button-icon is clicked and the user does not have permission', () => {
const rule = mockRule('id');
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={true} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={true}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
expect(wrapper.find('[data-test-subj="rules-details-delete-rule"] button').exists()).toEqual(
@ -231,7 +317,11 @@ describe('RuleActionsOverflow', () => {
test('it closes the popover when rules-details-delete-rule is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -244,7 +334,11 @@ describe('RuleActionsOverflow', () => {
test('it calls deleteRulesAction when rules-details-delete-rule is clicked', () => {
const wrapper = mount(
<RuleActionsOverflow rule={mockRule('id')} userHasNoPermissions={false} />
<RuleActionsOverflow
rule={mockRule('id')}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
@ -255,7 +349,13 @@ describe('RuleActionsOverflow', () => {
test('it calls deleteRulesAction with the rule.id when rules-details-delete-rule is clicked', () => {
const rule = mockRule('id');
const wrapper = mount(<RuleActionsOverflow rule={rule} userHasNoPermissions={false} />);
const wrapper = mount(
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={false}
canDuplicateRuleWithActions={true}
/>
);
wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click');
wrapper.update();
wrapper.find('[data-test-subj="rules-details-delete-rule"] button').simulate('click');

View file

@ -26,6 +26,7 @@ import {
} from '../../../pages/detection_engine/rules/all/actions';
import { GenericDownloader } from '../../../../common/components/generic_downloader';
import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine';
import { getToolTipContent } from '../../../../common/utils/privileges';
const MyEuiButtonIcon = styled(EuiButtonIcon)`
&.euiButtonIcon {
@ -41,6 +42,7 @@ const MyEuiButtonIcon = styled(EuiButtonIcon)`
interface RuleActionsOverflowComponentProps {
rule: Rule | null;
userHasNoPermissions: boolean;
canDuplicateRuleWithActions: boolean;
}
/**
@ -49,6 +51,7 @@ interface RuleActionsOverflowComponentProps {
const RuleActionsOverflowComponent = ({
rule,
userHasNoPermissions,
canDuplicateRuleWithActions,
}: RuleActionsOverflowComponentProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [rulesToExport, setRulesToExport] = useState<string[]>([]);
@ -66,14 +69,19 @@ const RuleActionsOverflowComponent = ({
<EuiContextMenuItem
key={i18nActions.DUPLICATE_RULE}
icon="copy"
disabled={userHasNoPermissions}
disabled={!canDuplicateRuleWithActions || userHasNoPermissions}
data-test-subj="rules-details-duplicate-rule"
onClick={async () => {
setIsPopoverOpen(false);
await duplicateRulesAction([rule], [rule.id], noop, dispatchToaster);
}}
>
{i18nActions.DUPLICATE_RULE}
<EuiToolTip
position="left"
content={getToolTipContent(rule, true, canDuplicateRuleWithActions)}
>
<>{i18nActions.DUPLICATE_RULE}</>
</EuiToolTip>
</EuiContextMenuItem>,
<EuiContextMenuItem
key={i18nActions.EXPORT_RULE}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiContextMenuItem } from '@elastic/eui';
import { EuiContextMenuItem, EuiToolTip } from '@elastic/eui';
import React, { Dispatch } from 'react';
import * as i18n from '../translations';
import { Action } from './reducer';
@ -18,12 +18,14 @@ import { ActionToaster, displayWarningToast } from '../../../../../common/compon
import { Rule } from '../../../../containers/detection_engine/rules';
import * as detectionI18n from '../../translations';
import { isMlRule } from '../../../../../../common/machine_learning/helpers';
import { canEditRuleWithActions } from '../../../../../common/utils/privileges';
interface GetBatchItems {
closePopover: () => void;
dispatch: Dispatch<Action>;
dispatchToaster: Dispatch<ActionToaster>;
hasMlPermissions: boolean;
hasActionsPrivileges: boolean;
loadingRuleIds: string[];
reFetchRules: (refreshPrePackagedRule?: boolean) => void;
rules: Rule[];
@ -39,31 +41,38 @@ export const getBatchItems = ({
reFetchRules,
rules,
selectedRuleIds,
hasActionsPrivileges,
}: GetBatchItems) => {
const containsEnabled = selectedRuleIds.some(
(id) => rules.find((r) => r.id === id)?.enabled ?? false
);
const containsDisabled = selectedRuleIds.some(
(id) => !rules.find((r) => r.id === id)?.enabled ?? false
);
const selectedRules = selectedRuleIds.reduce((acc, id) => {
const found = rules.find((r) => r.id === id);
if (found != null) {
return { [id]: found, ...acc };
}
return acc;
}, {} as Record<string, Rule>);
const containsEnabled = selectedRuleIds.some((id) => selectedRules[id]?.enabled ?? false);
const containsDisabled = selectedRuleIds.some((id) => !selectedRules[id]?.enabled ?? false);
const containsLoading = selectedRuleIds.some((id) => loadingRuleIds.includes(id));
const containsImmutable = selectedRuleIds.some(
(id) => rules.find((r) => r.id === id)?.immutable ?? false
);
const containsImmutable = selectedRuleIds.some((id) => selectedRules[id]?.immutable ?? false);
const missingActionPrivileges =
!hasActionsPrivileges &&
selectedRuleIds.some((id) => {
return !canEditRuleWithActions(selectedRules[id], hasActionsPrivileges);
});
return [
<EuiContextMenuItem
key={i18n.BATCH_ACTION_ACTIVATE_SELECTED}
icon="checkInCircleFilled"
disabled={containsLoading || !containsDisabled}
disabled={missingActionPrivileges || containsLoading || !containsDisabled}
onClick={async () => {
closePopover();
const deactivatedIds = selectedRuleIds.filter(
(id) => !rules.find((r) => r.id === id)?.enabled ?? false
);
const deactivatedIds = selectedRuleIds.filter((id) => !selectedRules[id]?.enabled ?? false);
const deactivatedIdsNoML = deactivatedIds.filter(
(id) => !isMlRule(rules.find((r) => r.id === id)?.type)
(id) => !isMlRule(selectedRules[id]?.type)
);
const mlRuleCount = deactivatedIds.length - deactivatedIdsNoML.length;
@ -79,21 +88,29 @@ export const getBatchItems = ({
);
}}
>
{i18n.BATCH_ACTION_ACTIVATE_SELECTED}
<EuiToolTip
position="right"
content={missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined}
>
<>{i18n.BATCH_ACTION_ACTIVATE_SELECTED}</>
</EuiToolTip>
</EuiContextMenuItem>,
<EuiContextMenuItem
key={i18n.BATCH_ACTION_DEACTIVATE_SELECTED}
icon="crossInACircleFilled"
disabled={containsLoading || !containsEnabled}
disabled={missingActionPrivileges || containsLoading || !containsEnabled}
onClick={async () => {
closePopover();
const activatedIds = selectedRuleIds.filter(
(id) => rules.find((r) => r.id === id)?.enabled ?? false
);
const activatedIds = selectedRuleIds.filter((id) => selectedRules[id]?.enabled ?? false);
await enableRulesAction(activatedIds, false, dispatch, dispatchToaster);
}}
>
{i18n.BATCH_ACTION_DEACTIVATE_SELECTED}
<EuiToolTip
position="right"
content={missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined}
>
<>{i18n.BATCH_ACTION_DEACTIVATE_SELECTED}</>
</EuiToolTip>
</EuiContextMenuItem>,
<EuiContextMenuItem
key={i18n.BATCH_ACTION_EXPORT_SELECTED}
@ -109,10 +126,11 @@ export const getBatchItems = ({
>
{i18n.BATCH_ACTION_EXPORT_SELECTED}
</EuiContextMenuItem>,
<EuiContextMenuItem
key={i18n.BATCH_ACTION_DUPLICATE_SELECTED}
icon="copy"
disabled={containsLoading || selectedRuleIds.length === 0}
disabled={missingActionPrivileges || containsLoading || selectedRuleIds.length === 0}
onClick={async () => {
closePopover();
await duplicateRulesAction(
@ -124,7 +142,12 @@ export const getBatchItems = ({
reFetchRules(true);
}}
>
{i18n.BATCH_ACTION_DUPLICATE_SELECTED}
<EuiToolTip
position="right"
content={missingActionPrivileges ? i18n.EDIT_RULE_SETTINGS_TOOLTIP : undefined}
>
<>{i18n.BATCH_ACTION_DUPLICATE_SELECTED}</>
</EuiToolTip>
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="deleteRuleBulk"

View file

@ -52,7 +52,8 @@ describe('AllRulesTable Columns', () => {
dispatch,
dispatchToaster,
history,
reFetchRules
reFetchRules,
true
)[1];
await duplicateRulesActionObject.onClick(rule);
expect(results).toEqual(['duplicateRulesAction', 'reFetchRules']);
@ -73,7 +74,8 @@ describe('AllRulesTable Columns', () => {
dispatch,
dispatchToaster,
history,
reFetchRules
reFetchRules,
true
)[3];
await deleteRulesActionObject.onClick(rule);
expect(results).toEqual(['deleteRulesAction', 'reFetchRules']);

View file

@ -35,27 +35,46 @@ import {
} from './actions';
import { Action } from './reducer';
import { LocalizedDateTooltip } from '../../../../../common/components/localized_date_tooltip';
import * as detectionI18n from '../../translations';
import { LinkAnchor } from '../../../../../common/components/links';
import { getToolTipContent, canEditRuleWithActions } from '../../../../../common/utils/privileges';
import { TagsDisplay } from './tag_display';
export const getActions = (
dispatch: React.Dispatch<Action>,
dispatchToaster: Dispatch<ActionToaster>,
history: H.History,
reFetchRules: (refreshPrePackagedRule?: boolean) => void
reFetchRules: (refreshPrePackagedRule?: boolean) => void,
actionsPrivileges:
| boolean
| Readonly<{
[x: string]: boolean;
}>
) => [
{
'data-test-subj': 'editRuleAction',
description: i18n.EDIT_RULE_SETTINGS,
name: !actionsPrivileges ? (
<EuiToolTip position="left" content={i18n.EDIT_RULE_SETTINGS_TOOLTIP}>
<>{i18n.EDIT_RULE_SETTINGS}</>
</EuiToolTip>
) : (
i18n.EDIT_RULE_SETTINGS
),
icon: 'controlsHorizontal',
name: i18n.EDIT_RULE_SETTINGS,
onClick: (rowItem: Rule) => editRuleAction(rowItem, history),
enabled: (rowItem: Rule) => canEditRuleWithActions(rowItem, actionsPrivileges),
},
{
description: i18n.DUPLICATE_RULE,
icon: 'copy',
name: i18n.DUPLICATE_RULE,
name: !actionsPrivileges ? (
<EuiToolTip position="left" content={i18n.EDIT_RULE_SETTINGS_TOOLTIP}>
<>{i18n.DUPLICATE_RULE}</>
</EuiToolTip>
) : (
i18n.DUPLICATE_RULE
),
enabled: (rowItem: Rule) => canEditRuleWithActions(rowItem, actionsPrivileges),
onClick: async (rowItem: Rule) => {
await duplicateRulesAction([rowItem], [rowItem.id], dispatch, dispatchToaster);
await reFetchRules(true);
@ -97,6 +116,11 @@ interface GetColumns {
hasNoPermissions: boolean;
loadingRuleIds: string[];
reFetchRules: (refreshPrePackagedRule?: boolean) => void;
hasReadActionsPrivileges:
| boolean
| Readonly<{
[x: string]: boolean;
}>;
}
export const getColumns = ({
@ -108,6 +132,7 @@ export const getColumns = ({
hasNoPermissions,
loadingRuleIds,
reFetchRules,
hasReadActionsPrivileges,
}: GetColumns): RulesColumns[] => {
const cols: RulesColumns[] = [
{
@ -227,11 +252,7 @@ export const getColumns = ({
render: (value: Rule['enabled'], item: Rule) => (
<EuiToolTip
position="top"
content={
isMlRule(item.type) && !hasMlPermissions
? detectionI18n.ML_RULES_DISABLED_MESSAGE
: undefined
}
content={getToolTipContent(item, hasMlPermissions, hasReadActionsPrivileges)}
>
<RuleSwitch
data-test-subj="enabled"
@ -239,7 +260,9 @@ export const getColumns = ({
id={item.id}
enabled={item.enabled}
isDisabled={
hasNoPermissions || (isMlRule(item.type) && !hasMlPermissions && !item.enabled)
!canEditRuleWithActions(item, hasReadActionsPrivileges) ||
hasNoPermissions ||
(isMlRule(item.type) && !hasMlPermissions && !item.enabled)
}
isLoading={loadingRuleIds.includes(item.id)}
/>
@ -251,7 +274,13 @@ export const getColumns = ({
];
const actions: RulesColumns[] = [
{
actions: getActions(dispatch, dispatchToaster, history, reFetchRules),
actions: getActions(
dispatch,
dispatchToaster,
history,
reFetchRules,
hasReadActionsPrivileges
),
width: '40px',
} as EuiTableActionsColumnType<Rule>,
];

View file

@ -12,6 +12,7 @@ import '../../../../../common/mock/formatted_relative';
import { TestProviders } from '../../../../../common/mock';
import { waitFor } from '@testing-library/react';
import { AllRules } from './index';
import { useKibana } from '../../../../../common/lib/kibana';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
@ -25,6 +26,9 @@ jest.mock('react-router-dom', () => {
});
jest.mock('../../../../../common/components/link_to');
jest.mock('../../../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
jest.mock('./reducer', () => {
return {
@ -160,6 +164,14 @@ jest.mock('react-router-dom', () => {
});
describe('AllRules', () => {
beforeEach(() => {
useKibanaMock().services.application.capabilities = {
navLinks: {},
management: {},
catalogue: {},
actions: { show: true },
};
});
it('renders correctly', () => {
const wrapper = shallow(
<AllRules

View file

@ -34,6 +34,7 @@ import {
UtilityBarSection,
UtilityBarText,
} from '../../../../../common/components/utility_bar';
import { useKibana } from '../../../../../common/lib/kibana';
import { useStateToaster } from '../../../../../common/components/toasters';
import { Loader } from '../../../../../common/components/loader';
import { Panel } from '../../../../../common/components/panel';
@ -53,6 +54,7 @@ import { hasMlAdminPermissions } from '../../../../../../common/machine_learning
import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license';
import { SecurityPageName } from '../../../../../app/types';
import { useFormatUrl } from '../../../../../common/components/link_to';
import { isBoolean } from '../../../../../common/utils/privileges';
const INITIAL_SORT_FIELD = 'enabled';
const initialState: State = {
@ -179,6 +181,17 @@ export const AllRules = React.memo<AllRulesProps>(
rulesNotInstalled,
rulesNotUpdated
);
const {
services: {
application: {
capabilities: { actions },
},
},
} = useKibana();
const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [
actions,
]);
const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => (
@ -188,6 +201,7 @@ export const AllRules = React.memo<AllRulesProps>(
dispatch,
dispatchToaster,
hasMlPermissions,
hasActionsPrivileges,
loadingRuleIds,
selectedRuleIds,
reFetchRules: reFetchRulesData,
@ -203,6 +217,7 @@ export const AllRules = React.memo<AllRulesProps>(
reFetchRulesData,
rules,
selectedRuleIds,
hasActionsPrivileges,
]
);
@ -244,6 +259,7 @@ export const AllRules = React.memo<AllRulesProps>(
? loadingRuleIds
: [],
reFetchRules: reFetchRulesData,
hasReadActionsPrivileges: hasActionsPrivileges,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [

View file

@ -23,6 +23,7 @@ import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'reac
import { useParams, useHistory } from 'react-router-dom';
import { connect, ConnectedProps } from 'react-redux';
import { useKibana } from '../../../../../common/lib/kibana';
import { TimelineId } from '../../../../../../common/types/timeline';
import { UpdateDateRange } from '../../../../../common/components/charts/common';
import { FiltersGlobal } from '../../../../../common/components/filters_global';
@ -88,6 +89,12 @@ import { timelineDefaults } from '../../../../../timelines/store/timeline/defaul
import { TimelineModel } from '../../../../../timelines/store/timeline/model';
import { useSourcererScope } from '../../../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import {
getToolTipContent,
canEditRuleWithActions,
isBoolean,
} from '../../../../../common/utils/privileges';
import { AlertsHistogramOption } from '../../../../components/alerts_histogram_panel/types';
enum RuleDetailTabs {
@ -166,6 +173,19 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
// TODO: Refactor license check + hasMlAdminPermissions to common check
const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities);
const ruleDetailTabs = getRuleDetailsTabs(rule);
const {
services: {
application: {
capabilities: { actions },
},
},
} = useKibana();
const hasActionsPrivileges = useMemo(() => {
if (rule?.actions != null && rule?.actions.length > 0 && isBoolean(actions.show)) {
return actions.show;
}
return true;
}, [actions, rule?.actions]);
// persist rule until refresh is complete
useEffect(() => {
@ -297,6 +317,33 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
[history, ruleId]
);
const editRule = useMemo(() => {
if (!hasActionsPrivileges) {
return (
<EuiToolTip position="top" content={ruleI18n.EDIT_RULE_SETTINGS_TOOLTIP}>
<LinkButton
onClick={goToEditRule}
iconType="controlsHorizontal"
isDisabled={true}
href={formatUrl(getEditRuleUrl(ruleId ?? ''))}
>
{ruleI18n.EDIT_RULE_SETTINGS}
</LinkButton>
</EuiToolTip>
);
}
return (
<LinkButton
onClick={goToEditRule}
iconType="controlsHorizontal"
isDisabled={userHasNoPermissions(canUserCRUD) ?? true}
href={formatUrl(getEditRuleUrl(ruleId ?? ''))}
>
{ruleI18n.EDIT_RULE_SETTINGS}
</LinkButton>
);
}, [canUserCRUD, formatUrl, goToEditRule, hasActionsPrivileges, ruleId]);
const onShowBuildingBlockAlertsChangedCallback = useCallback(
(newShowBuildingBlockAlerts: boolean) => {
setShowBuildingBlockAlerts(newShowBuildingBlockAlerts);
@ -390,16 +437,14 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={
isMlRule(rule?.type) && !hasMlPermissions
? detectionI18n.ML_RULES_DISABLED_MESSAGE
: undefined
}
content={getToolTipContent(rule, hasMlPermissions, hasActionsPrivileges)}
>
<RuleSwitch
id={rule?.id ?? '-1'}
isDisabled={
userHasNoPermissions(canUserCRUD) || (!hasMlPermissions && !rule?.enabled)
!canEditRuleWithActions(rule, hasActionsPrivileges) ||
userHasNoPermissions(canUserCRUD) ||
(!hasMlPermissions && !rule?.enabled)
}
enabled={rule?.enabled ?? false}
optionLabel={i18n.ACTIVATE_RULE}
@ -410,20 +455,15 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<LinkButton
onClick={goToEditRule}
iconType="controlsHorizontal"
isDisabled={userHasNoPermissions(canUserCRUD) ?? true}
href={formatUrl(getEditRuleUrl(ruleId ?? ''))}
>
{ruleI18n.EDIT_RULE_SETTINGS}
</LinkButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>{editRule}</EuiFlexItem>
<EuiFlexItem grow={false}>
<RuleActionsOverflow
rule={rule}
userHasNoPermissions={userHasNoPermissions(canUserCRUD)}
canDuplicateRuleWithActions={canEditRuleWithActions(
rule,
hasActionsPrivileges
)}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -214,6 +214,13 @@ export const EDIT_RULE_SETTINGS = i18n.translate(
}
);
export const EDIT_RULE_SETTINGS_TOOLTIP = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip',
{
defaultMessage: 'You do not have Kibana Actions privileges',
}
);
export const DUPLICATE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateTitle',
{