[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:
Xavier Mouligneau 2020-01-11 08:19:01 -05:00 committed by GitHub
parent 10733b5415
commit b057f18d16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 952 additions and 338 deletions

View file

@ -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;
}
`;

View file

@ -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

View file

@ -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(
(

View file

@ -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;
};

View file

@ -132,3 +132,7 @@ export interface DeleteRulesProps {
export interface DuplicateRulesProps {
rules: Rules;
}
export interface BasicFetchProps {
signal: AbortSignal;
}

View file

@ -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',
}
);

View file

@ -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 };
};

View file

@ -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 });
}
}
}

View file

@ -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);

View file

@ -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',
}
);

View file

@ -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;
};

View file

@ -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}

View file

@ -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

View file

@ -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(

View file

@ -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,
};
};

View file

@ -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 />
</>
);

View file

@ -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';

View file

@ -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];
};

View file

@ -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>

View file

@ -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);

View file

@ -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',
});

View file

@ -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}

View file

@ -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}
/>

View file

@ -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 => ({

View file

@ -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}
/>

View file

@ -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}
/>
)}

View file

@ -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>

View file

@ -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);

View file

@ -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 />

View file

@ -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[];
}

View file

@ -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');
});
});

View file

@ -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;
}
}
};

View file

@ -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);

View file

@ -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'],