[SIEM] [Detection engine] Permission II (#54292)
* allow read only user with no CRUD * use ../../lib/kibana * fix timeline-template * add re-routing on page * bug * cleanup * review I * review II * a pretty shameful bug I will live thanks Frank * bug select rule * only activate deactivate if user has the manage permission * add permissions rule with manage api key * bug on batch action for rules * add permissions to write status on signal
This commit is contained in:
parent
10733b5415
commit
b057f18d16
|
@ -20,7 +20,7 @@ import * as i18n from './translations';
|
|||
|
||||
const InspectContainer = styled.div<{ showInspect: boolean }>`
|
||||
.euiButtonIcon {
|
||||
${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0')}
|
||||
${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0;')}
|
||||
transition: opacity ${props => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -105,7 +105,7 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] =
|
|||
showInspect={false}
|
||||
>
|
||||
<div
|
||||
className="sc-AykKF cUIXEb"
|
||||
className="sc-AykKF kJqrPG"
|
||||
data-test-subj="transparent-inspect-container"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
|
@ -347,7 +347,7 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] =
|
|||
showInspect={false}
|
||||
>
|
||||
<div
|
||||
className="sc-AykKF cUIXEb"
|
||||
className="sc-AykKF kJqrPG"
|
||||
data-test-subj="transparent-inspect-container"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
|
@ -659,7 +659,7 @@ exports[`Stat Items Component rendering kpis with charts it renders the default
|
|||
showInspect={false}
|
||||
>
|
||||
<div
|
||||
className="sc-AykKF gGhbbL"
|
||||
className="sc-AykKF gblzeK"
|
||||
data-test-subj="transparent-inspect-container"
|
||||
>
|
||||
<EuiButtonIcon
|
||||
|
|
|
@ -149,20 +149,23 @@ const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProp
|
|||
);
|
||||
}, []);
|
||||
|
||||
const handleTimelineChange = useCallback(options => {
|
||||
const selectedTimeline = options.filter(
|
||||
(option: { checked: string }) => option.checked === 'on'
|
||||
);
|
||||
if (selectedTimeline != null && selectedTimeline.length > 0 && onTimelineChange != null) {
|
||||
onTimelineChange(
|
||||
isEmpty(selectedTimeline[0].title)
|
||||
? i18nTimeline.UNTITLED_TIMELINE
|
||||
: selectedTimeline[0].title,
|
||||
selectedTimeline[0].id
|
||||
const handleTimelineChange = useCallback(
|
||||
options => {
|
||||
const selectedTimeline = options.filter(
|
||||
(option: { checked: string }) => option.checked === 'on'
|
||||
);
|
||||
}
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
if (selectedTimeline != null && selectedTimeline.length > 0) {
|
||||
onTimelineChange(
|
||||
isEmpty(selectedTimeline[0].title)
|
||||
? i18nTimeline.UNTITLED_TIMELINE
|
||||
: selectedTimeline[0].title,
|
||||
selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id
|
||||
);
|
||||
}
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[onTimelineChange]
|
||||
);
|
||||
|
||||
const handleOnScroll = useCallback(
|
||||
(
|
||||
|
|
|
@ -15,9 +15,13 @@ import {
|
|||
NewRule,
|
||||
Rule,
|
||||
FetchRuleProps,
|
||||
BasicFetchProps,
|
||||
} from './types';
|
||||
import { throwIfNotOk } from '../../../hooks/api/api';
|
||||
import { DETECTION_ENGINE_RULES_URL } from '../../../../common/constants';
|
||||
import {
|
||||
DETECTION_ENGINE_RULES_URL,
|
||||
DETECTION_ENGINE_PREPACKAGED_URL,
|
||||
} from '../../../../common/constants';
|
||||
|
||||
/**
|
||||
* Add provided Rule
|
||||
|
@ -199,3 +203,22 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise<Ru
|
|||
responses.map<Promise<Rule>>(response => response.json())
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Prepackaged Rules
|
||||
*
|
||||
* @param signal AbortSignal for cancelling request
|
||||
*/
|
||||
export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise<boolean> => {
|
||||
const response = await fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_PREPACKAGED_URL}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'kbn-xsrf': 'true',
|
||||
},
|
||||
signal,
|
||||
});
|
||||
await throwIfNotOk(response);
|
||||
return true;
|
||||
};
|
||||
|
|
|
@ -132,3 +132,7 @@ export interface DeleteRulesProps {
|
|||
export interface DuplicateRulesProps {
|
||||
rules: Rules;
|
||||
}
|
||||
|
||||
export interface BasicFetchProps {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
|
|
@ -12,3 +12,24 @@ export const SIGNAL_FETCH_FAILURE = i18n.translate(
|
|||
defaultMessage: 'Failed to query signals',
|
||||
}
|
||||
);
|
||||
|
||||
export const PRIVILEGE_FETCH_FAILURE = i18n.translate(
|
||||
'xpack.siem.containers.detectionEngine.signals.errorFetchingSignalsDescription',
|
||||
{
|
||||
defaultMessage: 'Failed to query signals',
|
||||
}
|
||||
);
|
||||
|
||||
export const SIGNAL_GET_NAME_FAILURE = i18n.translate(
|
||||
'xpack.siem.containers.detectionEngine.signals.errorGetSignalDescription',
|
||||
{
|
||||
defaultMessage: 'Failed to get signal index name',
|
||||
}
|
||||
);
|
||||
|
||||
export const SIGNAL_POST_FAILURE = i18n.translate(
|
||||
'xpack.siem.containers.detectionEngine.signals.errorPostSignalDescription',
|
||||
{
|
||||
defaultMessage: 'Failed to create signal index',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -6,10 +6,18 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
|
||||
import { useStateToaster } from '../../../components/toasters';
|
||||
import { getUserPrivilege } from './api';
|
||||
import * as i18n from './translations';
|
||||
|
||||
type Return = [boolean, boolean | null, boolean | null];
|
||||
|
||||
interface Return {
|
||||
loading: boolean;
|
||||
isAuthenticated: boolean | null;
|
||||
hasIndexManage: boolean | null;
|
||||
hasManageApiKey: boolean | null;
|
||||
hasIndexWrite: boolean | null;
|
||||
}
|
||||
/**
|
||||
* Hook to get user privilege from
|
||||
*
|
||||
|
@ -17,7 +25,10 @@ type Return = [boolean, boolean | null, boolean | null];
|
|||
export const usePrivilegeUser = (): Return => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [hasWrite, setHasWrite] = useState<boolean | null>(null);
|
||||
const [hasIndexManage, setHasIndexManage] = useState<boolean | null>(null);
|
||||
const [hasIndexWrite, setHasIndexWrite] = useState<boolean | null>(null);
|
||||
const [hasManageApiKey, setHasManageApiKey] = useState<boolean | null>(null);
|
||||
const [, dispatchToaster] = useStateToaster();
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
|
@ -34,13 +45,21 @@ export const usePrivilegeUser = (): Return => {
|
|||
setAuthenticated(privilege.isAuthenticated);
|
||||
if (privilege.index != null && Object.keys(privilege.index).length > 0) {
|
||||
const indexName = Object.keys(privilege.index)[0];
|
||||
setHasWrite(privilege.index[indexName].create_index);
|
||||
setHasIndexManage(privilege.index[indexName].manage);
|
||||
setHasIndexWrite(privilege.index[indexName].write);
|
||||
setHasManageApiKey(
|
||||
privilege.cluster.manage_security ||
|
||||
privilege.cluster.manage_api_key ||
|
||||
privilege.cluster.manage_own_api_key
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSubscribed) {
|
||||
setAuthenticated(false);
|
||||
setHasWrite(false);
|
||||
setHasIndexManage(false);
|
||||
setHasIndexWrite(false);
|
||||
errorToToaster({ title: i18n.PRIVILEGE_FETCH_FAILURE, error, dispatchToaster });
|
||||
}
|
||||
}
|
||||
if (isSubscribed) {
|
||||
|
@ -55,5 +74,5 @@ export const usePrivilegeUser = (): Return => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
return [loading, isAuthenticated, hasWrite];
|
||||
return { loading, isAuthenticated, hasIndexManage, hasManageApiKey, hasIndexWrite };
|
||||
};
|
||||
|
|
|
@ -8,9 +8,10 @@ import { useEffect, useState, useRef } from 'react';
|
|||
|
||||
import { errorToToaster } from '../../../components/ml/api/error_to_toaster';
|
||||
import { useStateToaster } from '../../../components/toasters';
|
||||
import { createPrepackagedRules } from '../rules';
|
||||
import { createSignalIndex, getSignalIndex } from './api';
|
||||
import * as i18n from './translations';
|
||||
import { PostSignalError } from './types';
|
||||
import { PostSignalError, SignalIndexError } from './types';
|
||||
|
||||
type Func = () => void;
|
||||
|
||||
|
@ -40,11 +41,15 @@ export const useSignalIndex = (): Return => {
|
|||
if (isSubscribed && signal != null) {
|
||||
setSignalIndexName(signal.name);
|
||||
setSignalIndexExists(true);
|
||||
createPrepackagedRules({ signal: abortCtrl.signal });
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSubscribed) {
|
||||
setSignalIndexName(null);
|
||||
setSignalIndexExists(false);
|
||||
if (error instanceof SignalIndexError && error.statusCode !== 404) {
|
||||
errorToToaster({ title: i18n.SIGNAL_GET_NAME_FAILURE, error, dispatchToaster });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isSubscribed) {
|
||||
|
@ -69,7 +74,7 @@ export const useSignalIndex = (): Return => {
|
|||
} else {
|
||||
setSignalIndexName(null);
|
||||
setSignalIndexExists(false);
|
||||
errorToToaster({ title: i18n.SIGNAL_FETCH_FAILURE, error, dispatchToaster });
|
||||
errorToToaster({ title: i18n.SIGNAL_POST_FAILURE, error, dispatchToaster });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 { EuiCallOut, EuiButton } from '@elastic/eui';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
||||
const NoWriteSignalsCallOutComponent = () => {
|
||||
const [showCallOut, setShowCallOut] = useState(true);
|
||||
const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]);
|
||||
|
||||
return showCallOut ? (
|
||||
<EuiCallOut title={i18n.NO_WRITE_SIGNALS_CALLOUT_TITLE} color="warning" iconType="alert">
|
||||
<p>{i18n.NO_WRITE_SIGNALS_CALLOUT_MSG}</p>
|
||||
<EuiButton color="warning" onClick={handleCallOut}>
|
||||
{i18n.DISMISS_CALLOUT}
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const NoWriteSignalsCallOut = memo(NoWriteSignalsCallOutComponent);
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const NO_WRITE_SIGNALS_CALLOUT_TITLE = i18n.translate(
|
||||
'xpack.siem.detectionEngine.noWriteSignalsCallOutTitle',
|
||||
{
|
||||
defaultMessage: 'Signals index permissions required',
|
||||
}
|
||||
);
|
||||
|
||||
export const NO_WRITE_SIGNALS_CALLOUT_MSG = i18n.translate(
|
||||
'xpack.siem.detectionEngine.noWriteSignalsCallOutMsg',
|
||||
{
|
||||
defaultMessage:
|
||||
'You are currently missing the required permissions to update signals. Please contact your administrator for further assistance.',
|
||||
}
|
||||
);
|
||||
|
||||
export const DISMISS_CALLOUT = i18n.translate(
|
||||
'xpack.siem.detectionEngine.dismissNoWriteSignalButton',
|
||||
{
|
||||
defaultMessage: 'Dismiss',
|
||||
}
|
||||
);
|
|
@ -168,55 +168,66 @@ export const requiredFieldsForActions = [
|
|||
];
|
||||
|
||||
export const getSignalsActions = ({
|
||||
canUserCRUD,
|
||||
hasIndexWrite,
|
||||
setEventsLoading,
|
||||
setEventsDeleted,
|
||||
createTimeline,
|
||||
status,
|
||||
}: {
|
||||
canUserCRUD: boolean;
|
||||
hasIndexWrite: boolean;
|
||||
setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void;
|
||||
setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void;
|
||||
createTimeline: CreateTimeline;
|
||||
status: 'open' | 'closed';
|
||||
}): TimelineAction[] => [
|
||||
{
|
||||
getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
|
||||
<EuiToolTip
|
||||
data-test-subj="send-signal-to-timeline-tool-tip"
|
||||
content={i18n.ACTION_VIEW_IN_TIMELINE}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
data-test-subj={'send-signal-to-timeline-tool-tip'}
|
||||
onClick={() => sendSignalsToTimelineAction({ createTimeline, data: [data] })}
|
||||
iconType="tableDensityNormal"
|
||||
aria-label="Next"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
),
|
||||
id: 'sendSignalToTimeline',
|
||||
width: 26,
|
||||
},
|
||||
{
|
||||
getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
|
||||
<EuiToolTip
|
||||
data-test-subj="update-signal-status-tool-tip"
|
||||
content={status === FILTER_OPEN ? i18n.ACTION_OPEN_SIGNAL : i18n.ACTION_CLOSE_SIGNAL}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
data-test-subj={'update-signal-status-button'}
|
||||
onClick={() =>
|
||||
updateSignalStatusAction({
|
||||
signalIds: [eventId],
|
||||
status,
|
||||
setEventsLoading,
|
||||
setEventsDeleted,
|
||||
})
|
||||
}
|
||||
iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'}
|
||||
aria-label="Next"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
),
|
||||
id: 'updateSignalStatus',
|
||||
width: 26,
|
||||
},
|
||||
];
|
||||
}): TimelineAction[] => {
|
||||
const actions = [
|
||||
{
|
||||
getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
|
||||
<EuiToolTip
|
||||
data-test-subj="send-signal-to-timeline-tool-tip"
|
||||
content={i18n.ACTION_VIEW_IN_TIMELINE}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
data-test-subj={'send-signal-to-timeline-tool-tip'}
|
||||
onClick={() => sendSignalsToTimelineAction({ createTimeline, data: [data] })}
|
||||
iconType="tableDensityNormal"
|
||||
aria-label="Next"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
),
|
||||
id: 'sendSignalToTimeline',
|
||||
width: 26,
|
||||
},
|
||||
];
|
||||
return canUserCRUD && hasIndexWrite
|
||||
? [
|
||||
...actions,
|
||||
{
|
||||
getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => (
|
||||
<EuiToolTip
|
||||
data-test-subj="update-signal-status-tool-tip"
|
||||
content={status === FILTER_OPEN ? i18n.ACTION_OPEN_SIGNAL : i18n.ACTION_CLOSE_SIGNAL}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
data-test-subj={'update-signal-status-button'}
|
||||
onClick={() =>
|
||||
updateSignalStatusAction({
|
||||
signalIds: [eventId],
|
||||
status,
|
||||
setEventsLoading,
|
||||
setEventsDeleted,
|
||||
})
|
||||
}
|
||||
iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'}
|
||||
aria-label="Next"
|
||||
/>
|
||||
</EuiToolTip>
|
||||
),
|
||||
id: 'updateSignalStatus',
|
||||
width: 26,
|
||||
},
|
||||
]
|
||||
: actions;
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiPanel, EuiLoadingContent } from '@elastic/eui';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { ActionCreator } from 'typescript-fsa';
|
||||
|
@ -46,6 +47,8 @@ import { useFetchIndexPatterns } from '../../../../containers/detection_engine/r
|
|||
import { InputsRange } from '../../../../store/inputs/model';
|
||||
import { Query } from '../../../../../../../../../src/plugins/data/common/query';
|
||||
|
||||
import { HeaderSection } from '../../../../components/header_section';
|
||||
|
||||
const SIGNALS_PAGE_TIMELINE_ID = 'signals-page';
|
||||
|
||||
interface ReduxProps {
|
||||
|
@ -88,8 +91,11 @@ interface DispatchProps {
|
|||
}
|
||||
|
||||
interface OwnProps {
|
||||
canUserCRUD: boolean;
|
||||
defaultFilters?: esFilters.Filter[];
|
||||
hasIndexWrite: boolean;
|
||||
from: number;
|
||||
loading: boolean;
|
||||
signalsIndex: string;
|
||||
to: number;
|
||||
}
|
||||
|
@ -98,6 +104,7 @@ type SignalsTableComponentProps = OwnProps & ReduxProps & DispatchProps;
|
|||
|
||||
export const SignalsTableComponent = React.memo<SignalsTableComponentProps>(
|
||||
({
|
||||
canUserCRUD,
|
||||
createTimeline,
|
||||
clearEventsDeleted,
|
||||
clearEventsLoading,
|
||||
|
@ -106,7 +113,9 @@ export const SignalsTableComponent = React.memo<SignalsTableComponentProps>(
|
|||
from,
|
||||
globalFilters,
|
||||
globalQuery,
|
||||
hasIndexWrite,
|
||||
isSelectAllChecked,
|
||||
loading,
|
||||
loadingEventIds,
|
||||
removeTimelineLinkTo,
|
||||
selectedEventIds,
|
||||
|
@ -228,8 +237,10 @@ export const SignalsTableComponent = React.memo<SignalsTableComponentProps>(
|
|||
(totalCount: number) => {
|
||||
return (
|
||||
<SignalsUtilityBar
|
||||
canUserCRUD={canUserCRUD}
|
||||
areEventsLoading={loadingEventIds.length > 0}
|
||||
clearSelection={clearSelectionCallback}
|
||||
hasIndexWrite={hasIndexWrite}
|
||||
isFilteredToOpen={filterGroup === FILTER_OPEN}
|
||||
selectAll={selectAllCallback}
|
||||
selectedEventIds={selectedEventIds}
|
||||
|
@ -241,6 +252,8 @@ export const SignalsTableComponent = React.memo<SignalsTableComponentProps>(
|
|||
);
|
||||
},
|
||||
[
|
||||
canUserCRUD,
|
||||
hasIndexWrite,
|
||||
clearSelectionCallback,
|
||||
filterGroup,
|
||||
loadingEventIds.length,
|
||||
|
@ -254,12 +267,14 @@ export const SignalsTableComponent = React.memo<SignalsTableComponentProps>(
|
|||
const additionalActions = useMemo(
|
||||
() =>
|
||||
getSignalsActions({
|
||||
canUserCRUD,
|
||||
hasIndexWrite,
|
||||
createTimeline: createTimelineCallback,
|
||||
setEventsLoading: setEventsLoadingCallback,
|
||||
setEventsDeleted: setEventsDeletedCallback,
|
||||
status: filterGroup === FILTER_OPEN ? FILTER_CLOSED : FILTER_OPEN,
|
||||
}),
|
||||
[createTimelineCallback, filterGroup]
|
||||
[canUserCRUD, createTimelineCallback, filterGroup]
|
||||
);
|
||||
|
||||
const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]);
|
||||
|
@ -279,11 +294,20 @@ export const SignalsTableComponent = React.memo<SignalsTableComponentProps>(
|
|||
queryFields: requiredFieldsForActions,
|
||||
timelineActions: additionalActions,
|
||||
title: i18n.SIGNALS_TABLE_TITLE,
|
||||
selectAll,
|
||||
selectAll: canUserCRUD ? selectAll : false,
|
||||
}),
|
||||
[additionalActions, selectAll]
|
||||
[additionalActions, canUserCRUD, selectAll]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<EuiPanel>
|
||||
<HeaderSection title={i18n.SIGNALS_TABLE_TITLE} />
|
||||
<EuiLoadingContent />
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StatefulEventsViewer
|
||||
defaultIndices={defaultIndices}
|
||||
|
|
|
@ -11,6 +11,15 @@ import { TimelineNonEcsData } from '../../../../../graphql/types';
|
|||
import { SendSignalsToTimeline, UpdateSignalsStatus } from '../types';
|
||||
import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group';
|
||||
|
||||
interface GetBatchItems {
|
||||
areEventsLoading: boolean;
|
||||
allEventsSelected: boolean;
|
||||
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>;
|
||||
updateSignalsStatus: UpdateSignalsStatus;
|
||||
sendSignalsToTimeline: SendSignalsToTimeline;
|
||||
closePopover: () => void;
|
||||
isFilteredToOpen: boolean;
|
||||
}
|
||||
/**
|
||||
* Returns ViewInTimeline / UpdateSignalStatus actions to be display within an EuiContextMenuPanel
|
||||
*
|
||||
|
@ -22,15 +31,15 @@ import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group';
|
|||
* @param closePopover
|
||||
* @param isFilteredToOpen currently selected filter options
|
||||
*/
|
||||
export const getBatchItems = (
|
||||
areEventsLoading: boolean,
|
||||
allEventsSelected: boolean,
|
||||
selectedEventIds: Readonly<Record<string, TimelineNonEcsData[]>>,
|
||||
updateSignalsStatus: UpdateSignalsStatus,
|
||||
sendSignalsToTimeline: SendSignalsToTimeline,
|
||||
closePopover: () => void,
|
||||
isFilteredToOpen: boolean
|
||||
) => {
|
||||
export const getBatchItems = ({
|
||||
areEventsLoading,
|
||||
allEventsSelected,
|
||||
selectedEventIds,
|
||||
updateSignalsStatus,
|
||||
sendSignalsToTimeline,
|
||||
closePopover,
|
||||
isFilteredToOpen,
|
||||
}: GetBatchItems) => {
|
||||
const allDisabled = areEventsLoading || Object.keys(selectedEventIds).length === 0;
|
||||
const sendToTimelineDisabled = allEventsSelected || uniqueRuleCount(selectedEventIds) > 1;
|
||||
const filterString = isFilteredToOpen
|
||||
|
|
|
@ -22,6 +22,8 @@ import { TimelineNonEcsData } from '../../../../../graphql/types';
|
|||
import { SendSignalsToTimeline, UpdateSignalsStatus } from '../types';
|
||||
|
||||
interface SignalsUtilityBarProps {
|
||||
canUserCRUD: boolean;
|
||||
hasIndexWrite: boolean;
|
||||
areEventsLoading: boolean;
|
||||
clearSelection: () => void;
|
||||
isFilteredToOpen: boolean;
|
||||
|
@ -34,6 +36,8 @@ interface SignalsUtilityBarProps {
|
|||
}
|
||||
|
||||
const SignalsUtilityBarComponent: React.FC<SignalsUtilityBarProps> = ({
|
||||
canUserCRUD,
|
||||
hasIndexWrite,
|
||||
areEventsLoading,
|
||||
clearSelection,
|
||||
totalCount,
|
||||
|
@ -49,15 +53,15 @@ const SignalsUtilityBarComponent: React.FC<SignalsUtilityBarProps> = ({
|
|||
const getBatchItemsPopoverContent = useCallback(
|
||||
(closePopover: () => void) => (
|
||||
<EuiContextMenuPanel
|
||||
items={getBatchItems(
|
||||
items={getBatchItems({
|
||||
areEventsLoading,
|
||||
showClearSelection,
|
||||
allEventsSelected: showClearSelection,
|
||||
selectedEventIds,
|
||||
updateSignalsStatus,
|
||||
sendSignalsToTimeline,
|
||||
closePopover,
|
||||
isFilteredToOpen
|
||||
)}
|
||||
isFilteredToOpen,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
[
|
||||
|
@ -66,6 +70,7 @@ const SignalsUtilityBarComponent: React.FC<SignalsUtilityBarProps> = ({
|
|||
updateSignalsStatus,
|
||||
sendSignalsToTimeline,
|
||||
isFilteredToOpen,
|
||||
hasIndexWrite,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -83,7 +88,7 @@ const SignalsUtilityBarComponent: React.FC<SignalsUtilityBarProps> = ({
|
|||
</UtilityBarGroup>
|
||||
|
||||
<UtilityBarGroup>
|
||||
{totalCount > 0 && (
|
||||
{canUserCRUD && hasIndexWrite && (
|
||||
<>
|
||||
<UtilityBarText>
|
||||
{i18n.SELECTED_SIGNALS(
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
/*
|
||||
* 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 { noop } from 'lodash/fp';
|
||||
import React, { useEffect, useReducer, Dispatch, createContext, useContext } from 'react';
|
||||
|
||||
import { usePrivilegeUser } from '../../../../containers/detection_engine/signals/use_privilege_user';
|
||||
import { useSignalIndex } from '../../../../containers/detection_engine/signals/use_signal_index';
|
||||
import { useKibana } from '../../../../lib/kibana';
|
||||
|
||||
export interface State {
|
||||
canUserCRUD: boolean | null;
|
||||
hasIndexManage: boolean | null;
|
||||
hasIndexWrite: boolean | null;
|
||||
hasManageApiKey: boolean | null;
|
||||
isSignalIndexExists: boolean | null;
|
||||
isAuthenticated: boolean | null;
|
||||
loading: boolean;
|
||||
signalIndexName: string | null;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
canUserCRUD: null,
|
||||
hasIndexManage: null,
|
||||
hasIndexWrite: null,
|
||||
hasManageApiKey: null,
|
||||
isSignalIndexExists: null,
|
||||
isAuthenticated: null,
|
||||
loading: true,
|
||||
signalIndexName: null,
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| { type: 'updateLoading'; loading: boolean }
|
||||
| {
|
||||
type: 'updateHasManageApiKey';
|
||||
hasManageApiKey: boolean | null;
|
||||
}
|
||||
| {
|
||||
type: 'updateHasIndexManage';
|
||||
hasIndexManage: boolean | null;
|
||||
}
|
||||
| {
|
||||
type: 'updateHasIndexWrite';
|
||||
hasIndexWrite: boolean | null;
|
||||
}
|
||||
| {
|
||||
type: 'updateIsSignalIndexExists';
|
||||
isSignalIndexExists: boolean | null;
|
||||
}
|
||||
| {
|
||||
type: 'updateIsAuthenticated';
|
||||
isAuthenticated: boolean | null;
|
||||
}
|
||||
| {
|
||||
type: 'updateCanUserCRUD';
|
||||
canUserCRUD: boolean | null;
|
||||
}
|
||||
| {
|
||||
type: 'updateSignalIndexName';
|
||||
signalIndexName: string | null;
|
||||
};
|
||||
|
||||
export const userInfoReducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'updateLoading': {
|
||||
return {
|
||||
...state,
|
||||
loading: action.loading,
|
||||
};
|
||||
}
|
||||
case 'updateHasIndexManage': {
|
||||
return {
|
||||
...state,
|
||||
hasIndexManage: action.hasIndexManage,
|
||||
};
|
||||
}
|
||||
case 'updateHasIndexWrite': {
|
||||
return {
|
||||
...state,
|
||||
hasIndexWrite: action.hasIndexWrite,
|
||||
};
|
||||
}
|
||||
case 'updateHasManageApiKey': {
|
||||
return {
|
||||
...state,
|
||||
hasManageApiKey: action.hasManageApiKey,
|
||||
};
|
||||
}
|
||||
case 'updateIsSignalIndexExists': {
|
||||
return {
|
||||
...state,
|
||||
isSignalIndexExists: action.isSignalIndexExists,
|
||||
};
|
||||
}
|
||||
case 'updateIsAuthenticated': {
|
||||
return {
|
||||
...state,
|
||||
isAuthenticated: action.isAuthenticated,
|
||||
};
|
||||
}
|
||||
case 'updateCanUserCRUD': {
|
||||
return {
|
||||
...state,
|
||||
canUserCRUD: action.canUserCRUD,
|
||||
};
|
||||
}
|
||||
case 'updateSignalIndexName': {
|
||||
return {
|
||||
...state,
|
||||
signalIndexName: action.signalIndexName,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const StateUserInfoContext = createContext<[State, Dispatch<Action>]>([initialState, () => noop]);
|
||||
|
||||
const useUserData = () => useContext(StateUserInfoContext);
|
||||
|
||||
interface ManageUserInfoProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ManageUserInfo = ({ children }: ManageUserInfoProps) => (
|
||||
<StateUserInfoContext.Provider value={useReducer(userInfoReducer, initialState)}>
|
||||
{children}
|
||||
</StateUserInfoContext.Provider>
|
||||
);
|
||||
|
||||
export const useUserInfo = (): State => {
|
||||
const [
|
||||
{
|
||||
canUserCRUD,
|
||||
hasIndexManage,
|
||||
hasIndexWrite,
|
||||
hasManageApiKey,
|
||||
isSignalIndexExists,
|
||||
isAuthenticated,
|
||||
loading,
|
||||
signalIndexName,
|
||||
},
|
||||
dispatch,
|
||||
] = useUserData();
|
||||
const {
|
||||
loading: privilegeLoading,
|
||||
isAuthenticated: isApiAuthenticated,
|
||||
hasIndexManage: hasApiIndexManage,
|
||||
hasIndexWrite: hasApiIndexWrite,
|
||||
hasManageApiKey: hasApiManageApiKey,
|
||||
} = usePrivilegeUser();
|
||||
const [
|
||||
indexNameLoading,
|
||||
isApiSignalIndexExists,
|
||||
apiSignalIndexName,
|
||||
createSignalIndex,
|
||||
] = useSignalIndex();
|
||||
|
||||
const uiCapabilities = useKibana().services.application.capabilities;
|
||||
const capabilitiesCanUserCRUD: boolean =
|
||||
typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (loading !== privilegeLoading || indexNameLoading) {
|
||||
dispatch({ type: 'updateLoading', loading: privilegeLoading || indexNameLoading });
|
||||
}
|
||||
}, [loading, privilegeLoading, indexNameLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasIndexManage !== hasApiIndexManage && hasApiIndexManage != null) {
|
||||
dispatch({ type: 'updateHasIndexManage', hasIndexManage: hasApiIndexManage });
|
||||
}
|
||||
}, [hasIndexManage, hasApiIndexManage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasIndexWrite !== hasApiIndexWrite && hasApiIndexWrite != null) {
|
||||
dispatch({ type: 'updateHasIndexWrite', hasIndexWrite: hasApiIndexWrite });
|
||||
}
|
||||
}, [hasIndexWrite, hasApiIndexWrite]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasManageApiKey !== hasApiManageApiKey && hasApiManageApiKey != null) {
|
||||
dispatch({ type: 'updateHasManageApiKey', hasManageApiKey: hasApiManageApiKey });
|
||||
}
|
||||
}, [hasManageApiKey, hasApiManageApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSignalIndexExists !== isApiSignalIndexExists && isApiSignalIndexExists != null) {
|
||||
dispatch({ type: 'updateIsSignalIndexExists', isSignalIndexExists: isApiSignalIndexExists });
|
||||
}
|
||||
}, [isSignalIndexExists, isApiSignalIndexExists]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated !== isApiAuthenticated && isApiAuthenticated != null) {
|
||||
dispatch({ type: 'updateIsAuthenticated', isAuthenticated: isApiAuthenticated });
|
||||
}
|
||||
}, [isAuthenticated, isApiAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canUserCRUD !== capabilitiesCanUserCRUD && capabilitiesCanUserCRUD != null) {
|
||||
dispatch({ type: 'updateCanUserCRUD', canUserCRUD: capabilitiesCanUserCRUD });
|
||||
}
|
||||
}, [canUserCRUD, capabilitiesCanUserCRUD]);
|
||||
|
||||
useEffect(() => {
|
||||
if (signalIndexName !== apiSignalIndexName && apiSignalIndexName != null) {
|
||||
dispatch({ type: 'updateSignalIndexName', signalIndexName: apiSignalIndexName });
|
||||
}
|
||||
}, [signalIndexName, apiSignalIndexName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isAuthenticated &&
|
||||
hasIndexManage &&
|
||||
isSignalIndexExists != null &&
|
||||
!isSignalIndexExists &&
|
||||
createSignalIndex != null
|
||||
) {
|
||||
createSignalIndex();
|
||||
}
|
||||
}, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasIndexManage]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
isSignalIndexExists,
|
||||
isAuthenticated,
|
||||
canUserCRUD,
|
||||
hasIndexManage,
|
||||
hasIndexWrite,
|
||||
hasManageApiKey,
|
||||
signalIndexName,
|
||||
};
|
||||
};
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiButton, EuiLoadingContent, EuiPanel, EuiSpacer } from '@elastic/eui';
|
||||
import { EuiButton, EuiSpacer } from '@elastic/eui';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickyContainer } from 'react-sticky';
|
||||
|
||||
|
@ -18,30 +18,23 @@ import { GlobalTime } from '../../containers/global_time';
|
|||
import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source';
|
||||
import { SpyRoute } from '../../utils/route/spy_routes';
|
||||
|
||||
import { SignalsTable } from './components/signals';
|
||||
import * as signalsI18n from './components/signals/translations';
|
||||
import { SignalsHistogramPanel } from './components/signals_histogram_panel';
|
||||
import { Query } from '../../../../../../../src/plugins/data/common/query';
|
||||
import { esFilters } from '../../../../../../../src/plugins/data/common/es_query';
|
||||
import { inputsSelectors } from '../../store/inputs';
|
||||
import { State } from '../../store';
|
||||
import { inputsSelectors } from '../../store/inputs';
|
||||
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions';
|
||||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
import { InputsRange } from '../../store/inputs/model';
|
||||
import { signalsHistogramOptions } from './components/signals_histogram_panel/config';
|
||||
import { useSignalInfo } from './components/signals_info';
|
||||
import { SignalsTable } from './components/signals';
|
||||
import { NoWriteSignalsCallOut } from './components/no_write_signals_callout';
|
||||
import { SignalsHistogramPanel } from './components/signals_histogram_panel';
|
||||
import { signalsHistogramOptions } from './components/signals_histogram_panel/config';
|
||||
import { useUserInfo } from './components/user_info';
|
||||
import { DetectionEngineEmptyPage } from './detection_engine_empty_page';
|
||||
import { DetectionEngineNoIndex } from './detection_engine_no_signal_index';
|
||||
import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated';
|
||||
import * as i18n from './translations';
|
||||
import { HeaderSection } from '../../components/header_section';
|
||||
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions';
|
||||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
|
||||
interface OwnProps {
|
||||
loading: boolean;
|
||||
isSignalIndexExists: boolean | null;
|
||||
isUserAuthenticated: boolean | null;
|
||||
signalsIndex: string | null;
|
||||
}
|
||||
|
||||
interface ReduxProps {
|
||||
filters: esFilters.Filter[];
|
||||
|
@ -56,18 +49,19 @@ export interface DispatchProps {
|
|||
}>;
|
||||
}
|
||||
|
||||
type DetectionEngineComponentProps = OwnProps & ReduxProps & DispatchProps;
|
||||
type DetectionEngineComponentProps = ReduxProps & DispatchProps;
|
||||
|
||||
const DetectionEngineComponent = React.memo<DetectionEngineComponentProps>(
|
||||
({ filters, query, setAbsoluteRangeDatePicker }) => {
|
||||
const {
|
||||
loading,
|
||||
isSignalIndexExists,
|
||||
isAuthenticated: isUserAuthenticated,
|
||||
canUserCRUD,
|
||||
signalIndexName,
|
||||
hasIndexWrite,
|
||||
} = useUserInfo();
|
||||
|
||||
export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps>(
|
||||
({
|
||||
filters,
|
||||
loading,
|
||||
isSignalIndexExists,
|
||||
isUserAuthenticated,
|
||||
query,
|
||||
setAbsoluteRangeDatePicker,
|
||||
signalsIndex,
|
||||
}) => {
|
||||
const [lastSignals] = useSignalInfo({});
|
||||
|
||||
const updateDateRangeCallback = useCallback(
|
||||
|
@ -95,6 +89,7 @@ export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps
|
|||
}
|
||||
return (
|
||||
<>
|
||||
{hasIndexWrite != null && !hasIndexWrite && <NoWriteSignalsCallOut />}
|
||||
<WithSource sourceId="default">
|
||||
{({ indicesExist, indexPattern }) => {
|
||||
return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? (
|
||||
|
@ -102,7 +97,6 @@ export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps
|
|||
<FiltersGlobal>
|
||||
<SiemSearchBar id="global" indexPattern={indexPattern} />
|
||||
</FiltersGlobal>
|
||||
|
||||
<WrapperPage>
|
||||
<HeaderPage
|
||||
border
|
||||
|
@ -137,16 +131,14 @@ export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps
|
|||
|
||||
<EuiSpacer />
|
||||
|
||||
{!loading ? (
|
||||
isSignalIndexExists && (
|
||||
<SignalsTable from={from} signalsIndex={signalsIndex ?? ''} to={to} />
|
||||
)
|
||||
) : (
|
||||
<EuiPanel>
|
||||
<HeaderSection title={signalsI18n.SIGNALS_TABLE_TITLE} />
|
||||
<EuiLoadingContent />
|
||||
</EuiPanel>
|
||||
)}
|
||||
<SignalsTable
|
||||
loading={loading}
|
||||
hasIndexWrite={hasIndexWrite ?? false}
|
||||
canUserCRUD={canUserCRUD ?? false}
|
||||
from={from}
|
||||
signalsIndex={signalIndexName ?? ''}
|
||||
to={to}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</GlobalTime>
|
||||
|
@ -160,7 +152,6 @@ export const DetectionEngineComponent = React.memo<DetectionEngineComponentProps
|
|||
);
|
||||
}}
|
||||
</WithSource>
|
||||
|
||||
<SpyRoute />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -4,70 +4,38 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import { useSignalIndex } from '../../containers/detection_engine/signals/use_signal_index';
|
||||
import { usePrivilegeUser } from '../../containers/detection_engine/signals/use_privilege_user';
|
||||
|
||||
import { CreateRuleComponent } from './rules/create';
|
||||
import { DetectionEngine } from './detection_engine';
|
||||
import { EditRuleComponent } from './rules/edit';
|
||||
import { RuleDetails } from './rules/details';
|
||||
import { RulesComponent } from './rules';
|
||||
import { ManageUserInfo } from './components/user_info';
|
||||
|
||||
const detectionEnginePath = `/:pageName(detection-engine)`;
|
||||
|
||||
type Props = Partial<RouteComponentProps<{}>> & { url: string };
|
||||
|
||||
export const DetectionEngineContainer = React.memo<Props>(() => {
|
||||
const [privilegeLoading, isAuthenticated, hasWrite] = usePrivilegeUser();
|
||||
const [
|
||||
indexNameLoading,
|
||||
isSignalIndexExists,
|
||||
signalIndexName,
|
||||
createSignalIndex,
|
||||
] = useSignalIndex();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isAuthenticated &&
|
||||
hasWrite &&
|
||||
isSignalIndexExists != null &&
|
||||
!isSignalIndexExists &&
|
||||
createSignalIndex != null
|
||||
) {
|
||||
createSignalIndex();
|
||||
}
|
||||
}, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasWrite]);
|
||||
|
||||
return (
|
||||
export const DetectionEngineContainer = React.memo<Props>(() => (
|
||||
<ManageUserInfo>
|
||||
<Switch>
|
||||
<Route exact path={detectionEnginePath} strict>
|
||||
<DetectionEngine
|
||||
loading={indexNameLoading || privilegeLoading}
|
||||
isSignalIndexExists={isSignalIndexExists}
|
||||
isUserAuthenticated={isAuthenticated}
|
||||
signalsIndex={signalIndexName}
|
||||
/>
|
||||
<DetectionEngine />
|
||||
</Route>
|
||||
<Route exact path={`${detectionEnginePath}/rules`}>
|
||||
<RulesComponent />
|
||||
</Route>
|
||||
<Route exact path={`${detectionEnginePath}/rules/create`}>
|
||||
<CreateRuleComponent />
|
||||
</Route>
|
||||
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId`}>
|
||||
<RuleDetails />
|
||||
</Route>
|
||||
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId/edit`}>
|
||||
<EditRuleComponent />
|
||||
</Route>
|
||||
{isSignalIndexExists && isAuthenticated && (
|
||||
<>
|
||||
<Route exact path={`${detectionEnginePath}/rules`}>
|
||||
<RulesComponent />
|
||||
</Route>
|
||||
<Route exact path={`${detectionEnginePath}/rules/create`}>
|
||||
<CreateRuleComponent />
|
||||
</Route>
|
||||
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId`}>
|
||||
<RuleDetails signalsIndex={signalIndexName} />
|
||||
</Route>
|
||||
<Route exact path={`${detectionEnginePath}/rules/id/:ruleId/edit`}>
|
||||
<EditRuleComponent />
|
||||
</Route>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path="/detection-engine/"
|
||||
render={({ location: { search = '' } }) => (
|
||||
|
@ -75,6 +43,6 @@ export const DetectionEngineContainer = React.memo<Props>(() => {
|
|||
)}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
});
|
||||
</ManageUserInfo>
|
||||
));
|
||||
DetectionEngineContainer.displayName = 'DetectionEngineContainer';
|
||||
|
|
|
@ -68,111 +68,121 @@ const getActions = (dispatch: React.Dispatch<Action>, history: H.History) => [
|
|||
},
|
||||
];
|
||||
|
||||
type RulesColumns = EuiBasicTableColumn<TableData> | EuiTableActionsColumnType<TableData>;
|
||||
|
||||
// Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes?
|
||||
export const getColumns = (
|
||||
dispatch: React.Dispatch<Action>,
|
||||
history: H.History
|
||||
): Array<EuiBasicTableColumn<TableData> | EuiTableActionsColumnType<TableData>> => [
|
||||
{
|
||||
field: 'rule',
|
||||
name: i18n.COLUMN_RULE,
|
||||
render: (value: TableData['rule']) => <EuiLink href={value.href}>{value.name}</EuiLink>,
|
||||
truncateText: true,
|
||||
width: '24%',
|
||||
},
|
||||
{
|
||||
field: 'method',
|
||||
name: i18n.COLUMN_METHOD,
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
name: i18n.COLUMN_SEVERITY,
|
||||
render: (value: TableData['severity']) => (
|
||||
<EuiHealth
|
||||
color={
|
||||
value === 'low'
|
||||
? euiLightVars.euiColorVis0
|
||||
: value === 'medium'
|
||||
? euiLightVars.euiColorVis5
|
||||
: value === 'high'
|
||||
? euiLightVars.euiColorVis7
|
||||
: euiLightVars.euiColorVis9
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</EuiHealth>
|
||||
),
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'lastCompletedRun',
|
||||
name: i18n.COLUMN_LAST_COMPLETE_RUN,
|
||||
render: (value: TableData['lastCompletedRun']) => {
|
||||
return value == null ? (
|
||||
getEmptyTagValue()
|
||||
) : (
|
||||
<PreferenceFormattedDate value={new Date(value)} />
|
||||
);
|
||||
history: H.History,
|
||||
hasNoPermissions: boolean
|
||||
): RulesColumns[] => {
|
||||
const cols: RulesColumns[] = [
|
||||
{
|
||||
field: 'rule',
|
||||
name: i18n.COLUMN_RULE,
|
||||
render: (value: TableData['rule']) => <EuiLink href={value.href}>{value.name}</EuiLink>,
|
||||
truncateText: true,
|
||||
width: '24%',
|
||||
},
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
width: '16%',
|
||||
},
|
||||
{
|
||||
field: 'lastResponse',
|
||||
name: i18n.COLUMN_LAST_RESPONSE,
|
||||
render: (value: TableData['lastResponse']) => {
|
||||
return value == null ? (
|
||||
getEmptyTagValue()
|
||||
) : (
|
||||
<>
|
||||
{value.type === 'Fail' ? (
|
||||
<EuiTextColor color="danger">
|
||||
{value.type} <EuiIconTip content={value.message} type="iInCircle" />
|
||||
</EuiTextColor>
|
||||
) : (
|
||||
<EuiTextColor color="secondary">{value.type}</EuiTextColor>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{
|
||||
field: 'method',
|
||||
name: i18n.COLUMN_METHOD,
|
||||
truncateText: true,
|
||||
},
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'tags',
|
||||
name: i18n.COLUMN_TAGS,
|
||||
render: (value: TableData['tags']) => (
|
||||
<div>
|
||||
<>
|
||||
{value.map((tag, i) => (
|
||||
<EuiBadge color="hollow" key={i}>
|
||||
{tag}
|
||||
</EuiBadge>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
truncateText: true,
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
field: 'activate',
|
||||
name: i18n.COLUMN_ACTIVATE,
|
||||
render: (value: TableData['activate'], item: TableData) => (
|
||||
<RuleSwitch
|
||||
dispatch={dispatch}
|
||||
id={item.id}
|
||||
enabled={item.activate}
|
||||
isLoading={item.isLoading}
|
||||
/>
|
||||
),
|
||||
sortable: true,
|
||||
width: '85px',
|
||||
},
|
||||
{
|
||||
actions: getActions(dispatch, history),
|
||||
width: '40px',
|
||||
} as EuiTableActionsColumnType<TableData>,
|
||||
];
|
||||
{
|
||||
field: 'severity',
|
||||
name: i18n.COLUMN_SEVERITY,
|
||||
render: (value: TableData['severity']) => (
|
||||
<EuiHealth
|
||||
color={
|
||||
value === 'low'
|
||||
? euiLightVars.euiColorVis0
|
||||
: value === 'medium'
|
||||
? euiLightVars.euiColorVis5
|
||||
: value === 'high'
|
||||
? euiLightVars.euiColorVis7
|
||||
: euiLightVars.euiColorVis9
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</EuiHealth>
|
||||
),
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'lastCompletedRun',
|
||||
name: i18n.COLUMN_LAST_COMPLETE_RUN,
|
||||
render: (value: TableData['lastCompletedRun']) => {
|
||||
return value == null ? (
|
||||
getEmptyTagValue()
|
||||
) : (
|
||||
<PreferenceFormattedDate value={new Date(value)} />
|
||||
);
|
||||
},
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
width: '16%',
|
||||
},
|
||||
{
|
||||
field: 'lastResponse',
|
||||
name: i18n.COLUMN_LAST_RESPONSE,
|
||||
render: (value: TableData['lastResponse']) => {
|
||||
return value == null ? (
|
||||
getEmptyTagValue()
|
||||
) : (
|
||||
<>
|
||||
{value.type === 'Fail' ? (
|
||||
<EuiTextColor color="danger">
|
||||
{value.type} <EuiIconTip content={value.message} type="iInCircle" />
|
||||
</EuiTextColor>
|
||||
) : (
|
||||
<EuiTextColor color="secondary">{value.type}</EuiTextColor>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'tags',
|
||||
name: i18n.COLUMN_TAGS,
|
||||
render: (value: TableData['tags']) => (
|
||||
<div>
|
||||
<>
|
||||
{value.map((tag, i) => (
|
||||
<EuiBadge color="hollow" key={i}>
|
||||
{tag}
|
||||
</EuiBadge>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
truncateText: true,
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
field: 'activate',
|
||||
name: i18n.COLUMN_ACTIVATE,
|
||||
render: (value: TableData['activate'], item: TableData) => (
|
||||
<RuleSwitch
|
||||
dispatch={dispatch}
|
||||
id={item.id}
|
||||
enabled={item.activate}
|
||||
isDisabled={hasNoPermissions}
|
||||
isLoading={item.isLoading}
|
||||
/>
|
||||
),
|
||||
sortable: true,
|
||||
width: '85px',
|
||||
},
|
||||
];
|
||||
const actions: RulesColumns[] = [
|
||||
{
|
||||
actions: getActions(dispatch, history),
|
||||
width: '40px',
|
||||
} as EuiTableActionsColumnType<TableData>,
|
||||
];
|
||||
|
||||
return hasNoPermissions ? cols : [...cols, ...actions];
|
||||
};
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
EuiLoadingContent,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import uuid from 'uuid';
|
||||
|
@ -60,7 +60,11 @@ const initialState: State = {
|
|||
* * Delete
|
||||
* * Import/Export
|
||||
*/
|
||||
export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importCompleteToggle => {
|
||||
export const AllRules = React.memo<{
|
||||
hasNoPermissions: boolean;
|
||||
importCompleteToggle: boolean;
|
||||
loading: boolean;
|
||||
}>(({ hasNoPermissions, importCompleteToggle, loading }) => {
|
||||
const [
|
||||
{
|
||||
exportPayload,
|
||||
|
@ -111,6 +115,15 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp
|
|||
});
|
||||
}, [rulesData]);
|
||||
|
||||
const euiBasicTableSelectionProps = useMemo(
|
||||
() => ({
|
||||
selectable: (item: TableData) => !item.isLoading,
|
||||
onSelectionChange: (selected: TableData[]) =>
|
||||
dispatch({ type: 'setSelected', selectedItems: selected }),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<JSONDownloader
|
||||
|
@ -161,13 +174,15 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp
|
|||
|
||||
<UtilityBarGroup>
|
||||
<UtilityBarText>{i18n.SELECTED_RULES(selectedItems.length)}</UtilityBarText>
|
||||
<UtilityBarAction
|
||||
iconSide="right"
|
||||
iconType="arrowDown"
|
||||
popoverContent={getBatchItemsPopoverContent}
|
||||
>
|
||||
{i18n.BATCH_ACTIONS}
|
||||
</UtilityBarAction>
|
||||
{!hasNoPermissions && (
|
||||
<UtilityBarAction
|
||||
iconSide="right"
|
||||
iconType="arrowDown"
|
||||
popoverContent={getBatchItemsPopoverContent}
|
||||
>
|
||||
{i18n.BATCH_ACTIONS}
|
||||
</UtilityBarAction>
|
||||
)}
|
||||
<UtilityBarAction
|
||||
iconSide="right"
|
||||
iconType="refresh"
|
||||
|
@ -180,8 +195,8 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp
|
|||
</UtilityBar>
|
||||
|
||||
<EuiBasicTable
|
||||
columns={getColumns(dispatch, history)}
|
||||
isSelectable
|
||||
columns={getColumns(dispatch, history, hasNoPermissions)}
|
||||
isSelectable={!hasNoPermissions ?? false}
|
||||
itemId="rule_id"
|
||||
items={tableData}
|
||||
onChange={({ page, sort }: EuiBasicTableOnChange) => {
|
||||
|
@ -204,14 +219,12 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp
|
|||
totalItemCount: pagination.total,
|
||||
pageSizeOptions: [5, 10, 20],
|
||||
}}
|
||||
selection={{
|
||||
selectable: (item: TableData) => !item.isLoading,
|
||||
onSelectionChange: (selected: TableData[]) =>
|
||||
dispatch({ type: 'setSelected', selectedItems: selected }),
|
||||
}}
|
||||
sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }}
|
||||
selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps}
|
||||
/>
|
||||
{isLoading && <Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />}
|
||||
{(isLoading || loading) && (
|
||||
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 { EuiCallOut, EuiButton } from '@elastic/eui';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
||||
const ReadOnlyCallOutComponent = () => {
|
||||
const [showCallOut, setShowCallOut] = useState(true);
|
||||
const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]);
|
||||
|
||||
return showCallOut ? (
|
||||
<EuiCallOut title={i18n.READ_ONLY_CALLOUT_TITLE} color="warning" iconType="alert">
|
||||
<p>{i18n.READ_ONLY_CALLOUT_MSG}</p>
|
||||
<EuiButton color="warning" onClick={handleCallOut}>
|
||||
{i18n.DISMISS_CALLOUT}
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const ReadOnlyCallOut = memo(ReadOnlyCallOutComponent);
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const READ_ONLY_CALLOUT_TITLE = i18n.translate(
|
||||
'xpack.siem.detectionEngine.readOnlyCallOutTitle',
|
||||
{
|
||||
defaultMessage: 'Rule permissions required',
|
||||
}
|
||||
);
|
||||
|
||||
export const READ_ONLY_CALLOUT_MSG = i18n.translate(
|
||||
'xpack.siem.detectionEngine.readOnlyCallOutMsg',
|
||||
{
|
||||
defaultMessage:
|
||||
'You are currently missing the required permissions to create/edit detection engine rule. Please contact your administrator for further assistance.',
|
||||
}
|
||||
);
|
||||
|
||||
export const DISMISS_CALLOUT = i18n.translate('xpack.siem.detectionEngine.dismissButton', {
|
||||
defaultMessage: 'Dismiss',
|
||||
});
|
|
@ -11,7 +11,6 @@ exports[`RuleSwitch renders correctly against snapshot 1`] = `
|
|||
<StaticSwitch
|
||||
checked={true}
|
||||
data-test-subj="rule-switch"
|
||||
disabled={false}
|
||||
label="rule-switch"
|
||||
onChange={[Function]}
|
||||
showLabel={true}
|
||||
|
|
|
@ -32,6 +32,7 @@ export interface RuleSwitchProps {
|
|||
dispatch?: React.Dispatch<Action>;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
optionLabel?: string;
|
||||
}
|
||||
|
@ -42,6 +43,7 @@ export interface RuleSwitchProps {
|
|||
export const RuleSwitchComponent = ({
|
||||
dispatch,
|
||||
id,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
enabled,
|
||||
optionLabel,
|
||||
|
@ -92,7 +94,7 @@ export const RuleSwitchComponent = ({
|
|||
data-test-subj="rule-switch"
|
||||
label={optionLabel ?? ''}
|
||||
showLabel={!isEmpty(optionLabel)}
|
||||
disabled={false}
|
||||
disabled={isDisabled}
|
||||
checked={myEnabled}
|
||||
onChange={onRuleStateChange}
|
||||
/>
|
||||
|
|
|
@ -85,8 +85,12 @@ const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson =>
|
|||
false_positives: falsePositives.filter(item => !isEmpty(item)),
|
||||
references: references.filter(item => !isEmpty(item)),
|
||||
risk_score: riskScore,
|
||||
timeline_id: timeline.id,
|
||||
timeline_title: timeline.title,
|
||||
...(timeline.id != null && timeline.title != null
|
||||
? {
|
||||
timeline_id: timeline.id,
|
||||
timeline_title: timeline.title,
|
||||
}
|
||||
: {}),
|
||||
threats: threats
|
||||
.filter(threat => threat.tactic.name !== 'none')
|
||||
.map(threat => ({
|
||||
|
|
|
@ -14,6 +14,7 @@ import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redir
|
|||
import { WrapperPage } from '../../../../components/wrapper_page';
|
||||
import { usePersistRule } from '../../../../containers/detection_engine/rules';
|
||||
import { SpyRoute } from '../../../../utils/route/spy_routes';
|
||||
import { useUserInfo } from '../../components/user_info';
|
||||
import { AccordionTitle } from '../components/accordion_title';
|
||||
import { FormData, FormHook } from '../components/shared_imports';
|
||||
import { StepAboutRule } from '../components/step_about_rule';
|
||||
|
@ -56,6 +57,13 @@ const MyEuiPanel = styled(EuiPanel)`
|
|||
`;
|
||||
|
||||
export const CreateRuleComponent = React.memo(() => {
|
||||
const {
|
||||
loading,
|
||||
isSignalIndexExists,
|
||||
isAuthenticated,
|
||||
canUserCRUD,
|
||||
hasManageApiKey,
|
||||
} = useUserInfo();
|
||||
const [heightAccordion, setHeightAccordion] = useState(-1);
|
||||
const [openAccordionId, setOpenAccordionId] = useState<RuleStep>(RuleStep.defineRule);
|
||||
const defineRuleRef = useRef<EuiAccordion | null>(null);
|
||||
|
@ -77,6 +85,18 @@ export const CreateRuleComponent = React.memo(() => {
|
|||
[RuleStep.scheduleRule]: false,
|
||||
});
|
||||
const [{ isLoading, isSaved }, setRule] = usePersistRule();
|
||||
const userHasNoPermissions =
|
||||
canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false;
|
||||
|
||||
if (
|
||||
isSignalIndexExists != null &&
|
||||
isAuthenticated != null &&
|
||||
(!isSignalIndexExists || !isAuthenticated)
|
||||
) {
|
||||
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}`} />;
|
||||
} else if (userHasNoPermissions) {
|
||||
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}/rules`} />;
|
||||
}
|
||||
|
||||
const setStepData = useCallback(
|
||||
(step: RuleStep, data: unknown, isValid: boolean) => {
|
||||
|
@ -216,7 +236,7 @@ export const CreateRuleComponent = React.memo(() => {
|
|||
<HeaderPage
|
||||
backOptions={{ href: '#detection-engine/rules', text: 'Back to rules' }}
|
||||
border
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || loading}
|
||||
title={i18n.PAGE_TITLE}
|
||||
/>
|
||||
<ResizeEuiPanel height={heightAccordion}>
|
||||
|
@ -242,7 +262,7 @@ export const CreateRuleComponent = React.memo(() => {
|
|||
<EuiHorizontalRule margin="xs" />
|
||||
<StepDefineRule
|
||||
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.defineRule]}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || loading}
|
||||
setForm={setStepsForm}
|
||||
setStepData={setStepData}
|
||||
resizeParentContainer={height => setHeightAccordion(height)}
|
||||
|
@ -273,7 +293,7 @@ export const CreateRuleComponent = React.memo(() => {
|
|||
<EuiHorizontalRule margin="xs" />
|
||||
<StepAboutRule
|
||||
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.aboutRule]}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || loading}
|
||||
setForm={setStepsForm}
|
||||
setStepData={setStepData}
|
||||
/>
|
||||
|
@ -303,7 +323,7 @@ export const CreateRuleComponent = React.memo(() => {
|
|||
<EuiHorizontalRule margin="xs" />
|
||||
<StepScheduleRule
|
||||
isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.scheduleRule]}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || loading}
|
||||
setForm={setStepsForm}
|
||||
setStepData={setStepData}
|
||||
/>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import { StickyContainer } from 'react-sticky';
|
||||
|
||||
import { ActionCreator } from 'typescript-fsa';
|
||||
|
@ -28,13 +28,16 @@ import { SpyRoute } from '../../../../utils/route/spy_routes';
|
|||
|
||||
import { SignalsHistogramPanel } from '../../components/signals_histogram_panel';
|
||||
import { SignalsTable } from '../../components/signals';
|
||||
import { useUserInfo } from '../../components/user_info';
|
||||
import { DetectionEngineEmptyPage } from '../../detection_engine_empty_page';
|
||||
import { useSignalInfo } from '../../components/signals_info';
|
||||
import { StepAboutRule } from '../components/step_about_rule';
|
||||
import { StepDefineRule } from '../components/step_define_rule';
|
||||
import { StepScheduleRule } from '../components/step_schedule_rule';
|
||||
import { buildSignalsRuleIdFilter } from '../../components/signals/default_config';
|
||||
import { NoWriteSignalsCallOut } from '../../components/no_write_signals_callout';
|
||||
import * as detectionI18n from '../../translations';
|
||||
import { ReadOnlyCallOut } from '../components/read_only_callout';
|
||||
import { RuleSwitch } from '../components/rule_switch';
|
||||
import { StepPanel } from '../components/step_panel';
|
||||
import { getStepsData } from '../helpers';
|
||||
|
@ -50,10 +53,6 @@ import { State } from '../../../../store';
|
|||
import { InputsRange } from '../../../../store/inputs/model';
|
||||
import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions';
|
||||
|
||||
interface OwnProps {
|
||||
signalsIndex: string | null;
|
||||
}
|
||||
|
||||
interface ReduxProps {
|
||||
filters: esFilters.Filter[];
|
||||
query: Query;
|
||||
|
@ -67,22 +66,41 @@ export interface DispatchProps {
|
|||
}>;
|
||||
}
|
||||
|
||||
type RuleDetailsComponentProps = OwnProps & ReduxProps & DispatchProps;
|
||||
type RuleDetailsComponentProps = ReduxProps & DispatchProps;
|
||||
|
||||
const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
||||
({ filters, query, setAbsoluteRangeDatePicker, signalsIndex }) => {
|
||||
({ filters, query, setAbsoluteRangeDatePicker }) => {
|
||||
const {
|
||||
loading,
|
||||
isSignalIndexExists,
|
||||
isAuthenticated,
|
||||
canUserCRUD,
|
||||
hasManageApiKey,
|
||||
hasIndexWrite,
|
||||
signalIndexName,
|
||||
} = useUserInfo();
|
||||
const { ruleId } = useParams();
|
||||
const [loading, rule] = useRule(ruleId);
|
||||
const [isLoading, rule] = useRule(ruleId);
|
||||
const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({
|
||||
rule,
|
||||
detailsView: true,
|
||||
});
|
||||
const [lastSignals] = useSignalInfo({ ruleId });
|
||||
const userHasNoPermissions =
|
||||
canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false;
|
||||
|
||||
const title = loading === true || rule === null ? <EuiLoadingSpinner size="m" /> : rule.name;
|
||||
if (
|
||||
isSignalIndexExists != null &&
|
||||
isAuthenticated != null &&
|
||||
(!isSignalIndexExists || !isAuthenticated)
|
||||
) {
|
||||
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}`} />;
|
||||
}
|
||||
|
||||
const title = isLoading === true || rule === null ? <EuiLoadingSpinner size="m" /> : rule.name;
|
||||
const subTitle = useMemo(
|
||||
() =>
|
||||
loading === true || rule === null ? (
|
||||
isLoading === true || rule === null ? (
|
||||
<EuiLoadingSpinner size="m" />
|
||||
) : (
|
||||
[
|
||||
|
@ -118,7 +136,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
),
|
||||
]
|
||||
),
|
||||
[loading, rule]
|
||||
[isLoading, rule]
|
||||
);
|
||||
|
||||
const signalDefaultFilters = useMemo(
|
||||
|
@ -140,6 +158,8 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
|
||||
return (
|
||||
<>
|
||||
{hasIndexWrite != null && !hasIndexWrite && <NoWriteSignalsCallOut />}
|
||||
{userHasNoPermissions && <ReadOnlyCallOut />}
|
||||
<WithSource sourceId="default">
|
||||
{({ indicesExist, indexPattern }) => {
|
||||
return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? (
|
||||
|
@ -175,6 +195,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
<EuiFlexItem grow={false}>
|
||||
<RuleSwitch
|
||||
id={rule?.id ?? '-1'}
|
||||
isDisabled={userHasNoPermissions}
|
||||
enabled={rule?.enabled ?? false}
|
||||
optionLabel={i18n.ACTIVATE_RULE}
|
||||
/>
|
||||
|
@ -186,7 +207,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
<EuiButton
|
||||
href={`#${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}/edit`}
|
||||
iconType="visControls"
|
||||
isDisabled={rule?.immutable ?? true}
|
||||
isDisabled={(userHasNoPermissions || rule?.immutable) ?? true}
|
||||
>
|
||||
{ruleI18n.EDIT_RULE_SETTINGS}
|
||||
</EuiButton>
|
||||
|
@ -200,7 +221,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem component="section" grow={1}>
|
||||
<StepPanel loading={loading} title={ruleI18n.DEFINITION}>
|
||||
<StepPanel loading={isLoading} title={ruleI18n.DEFINITION}>
|
||||
{defineRuleData != null && (
|
||||
<StepDefineRule
|
||||
descriptionDirection="column"
|
||||
|
@ -213,7 +234,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem component="section" grow={2}>
|
||||
<StepPanel loading={loading} title={ruleI18n.ABOUT}>
|
||||
<StepPanel loading={isLoading} title={ruleI18n.ABOUT}>
|
||||
{aboutRuleData != null && (
|
||||
<StepAboutRule
|
||||
descriptionDirection="column"
|
||||
|
@ -226,7 +247,7 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem component="section" grow={1}>
|
||||
<StepPanel loading={loading} title={ruleI18n.SCHEDULE}>
|
||||
<StepPanel loading={isLoading} title={ruleI18n.SCHEDULE}>
|
||||
{scheduleRuleData != null && (
|
||||
<StepScheduleRule
|
||||
descriptionDirection="column"
|
||||
|
@ -253,9 +274,12 @@ const RuleDetailsComponent = memo<RuleDetailsComponentProps>(
|
|||
|
||||
{ruleId != null && (
|
||||
<SignalsTable
|
||||
canUserCRUD={canUserCRUD ?? false}
|
||||
defaultFilters={signalDefaultFilters}
|
||||
hasIndexWrite={hasIndexWrite ?? false}
|
||||
from={from}
|
||||
signalsIndex={signalsIndex ?? ''}
|
||||
loading={loading}
|
||||
signalsIndex={signalIndexName ?? ''}
|
||||
to={to}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -22,6 +22,7 @@ import { WrapperPage } from '../../../../components/wrapper_page';
|
|||
import { SpyRoute } from '../../../../utils/route/spy_routes';
|
||||
import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine';
|
||||
import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules';
|
||||
import { useUserInfo } from '../../components/user_info';
|
||||
import { FormHook, FormData } from '../components/shared_imports';
|
||||
import { StepPanel } from '../components/step_panel';
|
||||
import { StepAboutRule } from '../components/step_about_rule';
|
||||
|
@ -47,9 +48,28 @@ interface ScheduleStepRuleForm extends StepRuleForm {
|
|||
}
|
||||
|
||||
export const EditRuleComponent = memo(() => {
|
||||
const {
|
||||
loading: initLoading,
|
||||
isSignalIndexExists,
|
||||
isAuthenticated,
|
||||
canUserCRUD,
|
||||
hasManageApiKey,
|
||||
} = useUserInfo();
|
||||
const { ruleId } = useParams();
|
||||
const [loading, rule] = useRule(ruleId);
|
||||
|
||||
const userHasNoPermissions =
|
||||
canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false;
|
||||
if (
|
||||
isSignalIndexExists != null &&
|
||||
isAuthenticated != null &&
|
||||
(!isSignalIndexExists || !isAuthenticated)
|
||||
) {
|
||||
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}`} />;
|
||||
} else if (userHasNoPermissions) {
|
||||
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${ruleId}`} />;
|
||||
}
|
||||
|
||||
const [initForm, setInitForm] = useState(false);
|
||||
const [myAboutRuleForm, setMyAboutRuleForm] = useState<AboutStepRuleForm>({
|
||||
data: null,
|
||||
|
@ -89,7 +109,7 @@ export const EditRuleComponent = memo(() => {
|
|||
content: (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<StepPanel loading={loading} title={ruleI18n.DEFINITION}>
|
||||
<StepPanel loading={loading || initLoading} title={ruleI18n.DEFINITION}>
|
||||
{myDefineRuleForm.data != null && (
|
||||
<StepDefineRule
|
||||
isReadOnlyView={false}
|
||||
|
@ -110,7 +130,7 @@ export const EditRuleComponent = memo(() => {
|
|||
content: (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<StepPanel loading={loading} title={ruleI18n.ABOUT}>
|
||||
<StepPanel loading={loading || initLoading} title={ruleI18n.ABOUT}>
|
||||
{myAboutRuleForm.data != null && (
|
||||
<StepAboutRule
|
||||
isReadOnlyView={false}
|
||||
|
@ -131,7 +151,7 @@ export const EditRuleComponent = memo(() => {
|
|||
content: (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<StepPanel loading={loading} title={ruleI18n.SCHEDULE}>
|
||||
<StepPanel loading={loading || initLoading} title={ruleI18n.SCHEDULE}>
|
||||
{myScheduleRuleForm.data != null && (
|
||||
<StepScheduleRule
|
||||
isReadOnlyView={false}
|
||||
|
@ -149,6 +169,7 @@ export const EditRuleComponent = memo(() => {
|
|||
],
|
||||
[
|
||||
loading,
|
||||
initLoading,
|
||||
isLoading,
|
||||
myAboutRuleForm,
|
||||
myDefineRuleForm,
|
||||
|
@ -310,7 +331,13 @@ export const EditRuleComponent = memo(() => {
|
|||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton fill onClick={onSubmit} iconType="save" isLoading={isLoading}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={onSubmit}
|
||||
iconType="save"
|
||||
isLoading={isLoading}
|
||||
isDisabled={initLoading}
|
||||
>
|
||||
{i18n.SAVE_CHANGES}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { pick } from 'lodash/fp';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { esFilters } from '../../../../../../../../src/plugins/data/public';
|
||||
import { Rule } from '../../../containers/detection_engine/rules';
|
||||
|
@ -64,3 +65,5 @@ export const getStepsData = ({
|
|||
|
||||
return { aboutRuleData, defineRuleData, scheduleRuleData };
|
||||
};
|
||||
|
||||
export const useQuery = () => new URLSearchParams(useLocation().search);
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
*/
|
||||
|
||||
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { useState } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { DETECTION_ENGINE_PAGE_NAME } from '../../../components/link_to/redirect_to_detection_engine';
|
||||
import { FormattedRelativePreferenceDate } from '../../../components/formatted_date';
|
||||
|
@ -17,15 +18,34 @@ import { SpyRoute } from '../../../utils/route/spy_routes';
|
|||
|
||||
import { AllRules } from './all';
|
||||
import { ImportRuleModal } from './components/import_rule_modal';
|
||||
import { ReadOnlyCallOut } from './components/read_only_callout';
|
||||
import { useUserInfo } from '../components/user_info';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const RulesComponent = React.memo(() => {
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importCompleteToggle, setImportCompleteToggle] = useState(false);
|
||||
const {
|
||||
loading,
|
||||
isSignalIndexExists,
|
||||
isAuthenticated,
|
||||
canUserCRUD,
|
||||
hasManageApiKey,
|
||||
} = useUserInfo();
|
||||
|
||||
if (
|
||||
isSignalIndexExists != null &&
|
||||
isAuthenticated != null &&
|
||||
(!isSignalIndexExists || !isAuthenticated)
|
||||
) {
|
||||
return <Redirect to={`/${DETECTION_ENGINE_PAGE_NAME}`} />;
|
||||
}
|
||||
const userHasNoPermissions =
|
||||
canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false;
|
||||
const lastCompletedRun = undefined;
|
||||
return (
|
||||
<>
|
||||
{userHasNoPermissions && <ReadOnlyCallOut />}
|
||||
<ImportRuleModal
|
||||
showModal={showImportModal}
|
||||
closeModal={() => setShowImportModal(false)}
|
||||
|
@ -56,6 +76,7 @@ export const RulesComponent = React.memo(() => {
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
iconType="importAction"
|
||||
isDisabled={userHasNoPermissions || loading}
|
||||
onClick={() => {
|
||||
setShowImportModal(true);
|
||||
}}
|
||||
|
@ -63,20 +84,23 @@ export const RulesComponent = React.memo(() => {
|
|||
{i18n.IMPORT_RULE}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
href={`#${DETECTION_ENGINE_PAGE_NAME}/rules/create`}
|
||||
iconType="plusInCircle"
|
||||
isDisabled={userHasNoPermissions || loading}
|
||||
>
|
||||
{i18n.ADD_NEW_RULE}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</HeaderPage>
|
||||
|
||||
<AllRules importCompleteToggle={importCompleteToggle} />
|
||||
<AllRules
|
||||
loading={loading}
|
||||
hasNoPermissions={userHasNoPermissions}
|
||||
importCompleteToggle={importCompleteToggle}
|
||||
/>
|
||||
</WrapperPage>
|
||||
|
||||
<SpyRoute />
|
||||
|
|
|
@ -109,8 +109,8 @@ export interface AboutStepRuleJson {
|
|||
references: string[];
|
||||
false_positives: string[];
|
||||
tags: string[];
|
||||
timeline_id: string | null;
|
||||
timeline_title: string | null;
|
||||
timeline_id?: string;
|
||||
timeline_title?: string;
|
||||
threats: IMitreEnterpriseAttack[];
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { getIndexExists } from './get_index_exists';
|
||||
|
||||
class StatusCode extends Error {
|
||||
status: number = -1;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
describe('get_index_exists', () => {
|
||||
test('it should return a true if you have _shards', async () => {
|
||||
const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 1 } });
|
||||
const indexExists = await getIndexExists(callWithRequest, 'some-index');
|
||||
expect(indexExists).toEqual(true);
|
||||
});
|
||||
|
||||
test('it should return a false if you do NOT have _shards', async () => {
|
||||
const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 0 } });
|
||||
const indexExists = await getIndexExists(callWithRequest, 'some-index');
|
||||
expect(indexExists).toEqual(false);
|
||||
});
|
||||
|
||||
test('it should return a false if it encounters a 404', async () => {
|
||||
const callWithRequest = jest.fn().mockImplementation(() => {
|
||||
throw new StatusCode(404, 'I am a 404 error');
|
||||
});
|
||||
const indexExists = await getIndexExists(callWithRequest, 'some-index');
|
||||
expect(indexExists).toEqual(false);
|
||||
});
|
||||
|
||||
test('it should reject if it encounters a non 404', async () => {
|
||||
const callWithRequest = jest.fn().mockImplementation(() => {
|
||||
throw new StatusCode(500, 'I am a 500 error');
|
||||
});
|
||||
await expect(getIndexExists(callWithRequest, 'some-index')).rejects.toThrow('I am a 500 error');
|
||||
});
|
||||
});
|
|
@ -4,15 +4,29 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { IndicesExistsParams } from 'elasticsearch';
|
||||
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
|
||||
import { CallWithRequest } from '../types';
|
||||
|
||||
export const getIndexExists = async (
|
||||
callWithRequest: CallWithRequest<IndicesExistsParams, CallClusterOptions, boolean>,
|
||||
callWithRequest: CallWithRequest<
|
||||
{ index: string; size: number; terminate_after: number; allow_no_indices: boolean },
|
||||
{},
|
||||
{ _shards: { total: number } }
|
||||
>,
|
||||
index: string
|
||||
): Promise<boolean> => {
|
||||
return callWithRequest('indices.exists', {
|
||||
index,
|
||||
});
|
||||
try {
|
||||
const response = await callWithRequest('search', {
|
||||
index,
|
||||
size: 0,
|
||||
terminate_after: 1,
|
||||
allow_no_indices: true,
|
||||
});
|
||||
return response._shards.total > 0;
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
return false;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -28,7 +28,9 @@ describe('create_rules', () => {
|
|||
jest.resetAllMocks();
|
||||
({ server, alertsClient, actionsClient, elasticsearch } = createMockServer());
|
||||
elasticsearch.getCluster = jest.fn().mockImplementation(() => ({
|
||||
callWithRequest: jest.fn().mockImplementation(() => true),
|
||||
callWithRequest: jest
|
||||
.fn()
|
||||
.mockImplementation((endpoint, params) => ({ _shards: { total: 1 } })),
|
||||
}));
|
||||
|
||||
createRulesRoute(server);
|
||||
|
|
|
@ -60,7 +60,7 @@ export class Plugin {
|
|||
],
|
||||
read: ['config'],
|
||||
},
|
||||
ui: ['show'],
|
||||
ui: ['show', 'crud'],
|
||||
},
|
||||
read: {
|
||||
api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'],
|
||||
|
|
Loading…
Reference in a new issue