From b057f18d16f7c862d82c0d3b75444e4905bd5e51 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Sat, 11 Jan 2020 08:19:01 -0500 Subject: [PATCH] [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 --- .../siem/public/components/inspect/index.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 6 +- .../timeline/search_super_select/index.tsx | 29 ++- .../containers/detection_engine/rules/api.ts | 25 +- .../detection_engine/rules/types.ts | 4 + .../detection_engine/signals/translations.ts | 21 ++ .../signals/use_privilege_user.tsx | 31 ++- .../signals/use_signal_index.tsx | 9 +- .../no_write_signals_callout/index.tsx | 26 ++ .../no_write_signals_callout/translations.ts | 29 +++ .../components/signals/default_config.tsx | 97 +++---- .../components/signals/index.tsx | 30 ++- .../signals_utility_bar/batch_actions.tsx | 27 +- .../signals/signals_utility_bar/index.tsx | 15 +- .../components/user_info/index.tsx | 238 ++++++++++++++++++ .../detection_engine/detection_engine.tsx | 69 +++-- .../public/pages/detection_engine/index.tsx | 70 ++---- .../detection_engine/rules/all/columns.tsx | 216 ++++++++-------- .../detection_engine/rules/all/index.tsx | 47 ++-- .../components/read_only_callout/index.tsx | 26 ++ .../read_only_callout/translations.ts | 26 ++ .../__snapshots__/index.test.tsx.snap | 1 - .../rules/components/rule_switch/index.tsx | 4 +- .../detection_engine/rules/create/helpers.ts | 8 +- .../detection_engine/rules/create/index.tsx | 28 ++- .../detection_engine/rules/details/index.tsx | 56 +++-- .../detection_engine/rules/edit/index.tsx | 35 ++- .../pages/detection_engine/rules/helpers.tsx | 3 + .../pages/detection_engine/rules/index.tsx | 32 ++- .../pages/detection_engine/rules/types.ts | 4 +- .../index/get_index_exists.test.ts | 44 ++++ .../index/get_index_exists.ts | 26 +- .../routes/rules/create_rules_route.test.ts | 4 +- x-pack/legacy/plugins/siem/server/plugin.ts | 2 +- 34 files changed, 952 insertions(+), 338 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx index 04d6d94d6624..a2a0ffdde34a 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx @@ -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; } `; diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 098f54640e4b..5ed750b519cb 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -105,7 +105,7 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = showInspect={false} >
{ - 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( ( diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index f9611995cdb0..b69a8de29e04 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -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>(response => response.json()) ); }; + +/** + * Create Prepackaged Rules + * + * @param signal AbortSignal for cancelling request + */ +export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => { + 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; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 655299c4a2a3..a329d96d444a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -132,3 +132,7 @@ export interface DeleteRulesProps { export interface DuplicateRulesProps { rules: Rules; } + +export interface BasicFetchProps { + signal: AbortSignal; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts index 5b5dc9e9699f..2b8f54e5438d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts @@ -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', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx index aa66df53d9fd..792ff29ad248 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx @@ -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(null); - const [hasWrite, setHasWrite] = useState(null); + const [hasIndexManage, setHasIndexManage] = useState(null); + const [hasIndexWrite, setHasIndexWrite] = useState(null); + const [hasManageApiKey, setHasManageApiKey] = useState(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 }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx index 1ff4422cf641..189d8a1bf3f7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx @@ -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 }); } } } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx new file mode 100644 index 000000000000..195053199845 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx @@ -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 ? ( + +

{i18n.NO_WRITE_SIGNALS_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const NoWriteSignalsCallOut = memo(NoWriteSignalsCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts new file mode 100644 index 000000000000..065d775e1dc6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts @@ -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', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx index 1a7ad5822a24..83b6ba690ec5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx @@ -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 => ( - - sendSignalsToTimelineAction({ createTimeline, data: [data] })} - iconType="tableDensityNormal" - aria-label="Next" - /> - - ), - id: 'sendSignalToTimeline', - width: 26, - }, - { - getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( - - - updateSignalStatusAction({ - signalIds: [eventId], - status, - setEventsLoading, - setEventsDeleted, - }) - } - iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} - aria-label="Next" - /> - - ), - id: 'updateSignalStatus', - width: 26, - }, -]; +}): TimelineAction[] => { + const actions = [ + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + sendSignalsToTimelineAction({ createTimeline, data: [data] })} + iconType="tableDensityNormal" + aria-label="Next" + /> + + ), + id: 'sendSignalToTimeline', + width: 26, + }, + ]; + return canUserCRUD && hasIndexWrite + ? [ + ...actions, + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + + updateSignalStatusAction({ + signalIds: [eventId], + status, + setEventsLoading, + setEventsDeleted, + }) + } + iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} + aria-label="Next" + /> + + ), + id: 'updateSignalStatus', + width: 26, + }, + ] + : actions; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index 47a78482cfb6..d149eb700ad0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -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( ({ + canUserCRUD, createTimeline, clearEventsDeleted, clearEventsLoading, @@ -106,7 +113,9 @@ export const SignalsTableComponent = React.memo( from, globalFilters, globalQuery, + hasIndexWrite, isSelectAllChecked, + loading, loadingEventIds, removeTimelineLinkTo, selectedEventIds, @@ -228,8 +237,10 @@ export const SignalsTableComponent = React.memo( (totalCount: number) => { return ( 0} clearSelection={clearSelectionCallback} + hasIndexWrite={hasIndexWrite} isFilteredToOpen={filterGroup === FILTER_OPEN} selectAll={selectAllCallback} selectedEventIds={selectedEventIds} @@ -241,6 +252,8 @@ export const SignalsTableComponent = React.memo( ); }, [ + canUserCRUD, + hasIndexWrite, clearSelectionCallback, filterGroup, loadingEventIds.length, @@ -254,12 +267,14 @@ export const SignalsTableComponent = React.memo( 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( queryFields: requiredFieldsForActions, timelineActions: additionalActions, title: i18n.SIGNALS_TABLE_TITLE, - selectAll, + selectAll: canUserCRUD ? selectAll : false, }), - [additionalActions, selectAll] + [additionalActions, canUserCRUD, selectAll] ); + if (loading) { + return ( + + + + + ); + } + return ( >; + 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>, - 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 diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx index f80de053b59b..e28fb3e06870 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx @@ -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 = ({ + canUserCRUD, + hasIndexWrite, areEventsLoading, clearSelection, totalCount, @@ -49,15 +53,15 @@ const SignalsUtilityBarComponent: React.FC = ({ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( ), [ @@ -66,6 +70,7 @@ const SignalsUtilityBarComponent: React.FC = ({ updateSignalsStatus, sendSignalsToTimeline, isFilteredToOpen, + hasIndexWrite, ] ); @@ -83,7 +88,7 @@ const SignalsUtilityBarComponent: React.FC = ({ - {totalCount > 0 && ( + {canUserCRUD && hasIndexWrite && ( <> {i18n.SELECTED_SIGNALS( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx new file mode 100644 index 000000000000..bbaccb788248 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx @@ -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]>([initialState, () => noop]); + +const useUserData = () => useContext(StateUserInfoContext); + +interface ManageUserInfoProps { + children: React.ReactNode; +} + +export const ManageUserInfo = ({ children }: ManageUserInfoProps) => ( + + {children} + +); + +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, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index 2a91a559ec0e..e638cf89e77b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -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( + ({ filters, query, setAbsoluteRangeDatePicker }) => { + const { + loading, + isSignalIndexExists, + isAuthenticated: isUserAuthenticated, + canUserCRUD, + signalIndexName, + hasIndexWrite, + } = useUserInfo(); -export const DetectionEngineComponent = React.memo( - ({ - filters, - loading, - isSignalIndexExists, - isUserAuthenticated, - query, - setAbsoluteRangeDatePicker, - signalsIndex, - }) => { const [lastSignals] = useSignalInfo({}); const updateDateRangeCallback = useCallback( @@ -95,6 +89,7 @@ export const DetectionEngineComponent = React.memo + {hasIndexWrite != null && !hasIndexWrite && } {({ indicesExist, indexPattern }) => { return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( @@ -102,7 +97,6 @@ export const DetectionEngineComponent = React.memo - - {!loading ? ( - isSignalIndexExists && ( - - ) - ) : ( - - - - - )} + )} @@ -160,7 +152,6 @@ export const DetectionEngineComponent = React.memo - ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index c32cab7f933b..c4e83429aebd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -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> & { url: string }; -export const DetectionEngineContainer = React.memo(() => { - 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(() => ( + - + + + + + + + + + + + + + - {isSignalIndexExists && isAuthenticated && ( - <> - - - - - - - - - - - - - - )} - ( @@ -75,6 +43,6 @@ export const DetectionEngineContainer = React.memo(() => { )} /> - ); -}); + +)); DetectionEngineContainer.displayName = 'DetectionEngineContainer'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 42c4bb1d0ef9..95b9c9324894 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -68,111 +68,121 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ }, ]; +type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType; + // Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes? export const getColumns = ( dispatch: React.Dispatch, - history: H.History -): Array | EuiTableActionsColumnType> => [ - { - field: 'rule', - name: i18n.COLUMN_RULE, - render: (value: TableData['rule']) => {value.name}, - truncateText: true, - width: '24%', - }, - { - field: 'method', - name: i18n.COLUMN_METHOD, - truncateText: true, - }, - { - field: 'severity', - name: i18n.COLUMN_SEVERITY, - render: (value: TableData['severity']) => ( - - {value} - - ), - truncateText: true, - }, - { - field: 'lastCompletedRun', - name: i18n.COLUMN_LAST_COMPLETE_RUN, - render: (value: TableData['lastCompletedRun']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); + history: H.History, + hasNoPermissions: boolean +): RulesColumns[] => { + const cols: RulesColumns[] = [ + { + field: 'rule', + name: i18n.COLUMN_RULE, + render: (value: TableData['rule']) => {value.name}, + 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' ? ( - - {value.type} - - ) : ( - {value.type} - )} - - ); + { + field: 'method', + name: i18n.COLUMN_METHOD, + truncateText: true, }, - truncateText: true, - }, - { - field: 'tags', - name: i18n.COLUMN_TAGS, - render: (value: TableData['tags']) => ( -
- <> - {value.map((tag, i) => ( - - {tag} - - ))} - -
- ), - truncateText: true, - width: '20%', - }, - { - align: 'center', - field: 'activate', - name: i18n.COLUMN_ACTIVATE, - render: (value: TableData['activate'], item: TableData) => ( - - ), - sortable: true, - width: '85px', - }, - { - actions: getActions(dispatch, history), - width: '40px', - } as EuiTableActionsColumnType, -]; + { + field: 'severity', + name: i18n.COLUMN_SEVERITY, + render: (value: TableData['severity']) => ( + + {value} + + ), + truncateText: true, + }, + { + field: 'lastCompletedRun', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: TableData['lastCompletedRun']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + sortable: true, + truncateText: true, + width: '16%', + }, + { + field: 'lastResponse', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: TableData['lastResponse']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + <> + {value.type === 'Fail' ? ( + + {value.type} + + ) : ( + {value.type} + )} + + ); + }, + truncateText: true, + }, + { + field: 'tags', + name: i18n.COLUMN_TAGS, + render: (value: TableData['tags']) => ( +
+ <> + {value.map((tag, i) => ( + + {tag} + + ))} + +
+ ), + truncateText: true, + width: '20%', + }, + { + align: 'center', + field: 'activate', + name: i18n.COLUMN_ACTIVATE, + render: (value: TableData['activate'], item: TableData) => ( + + ), + sortable: true, + width: '85px', + }, + ]; + const actions: RulesColumns[] = [ + { + actions: getActions(dispatch, history), + width: '40px', + } as EuiTableActionsColumnType, + ]; + + return hasNoPermissions ? cols : [...cols, ...actions]; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 060f8baccc3b..e900058b6c53 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -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 ( <> (importComp {i18n.SELECTED_RULES(selectedItems.length)} - - {i18n.BATCH_ACTIONS} - + {!hasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} (importComp { @@ -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 && } + {(isLoading || loading) && ( + + )} )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx new file mode 100644 index 000000000000..6ec76bacc232 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx @@ -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 ? ( + +

{i18n.READ_ONLY_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const ReadOnlyCallOut = memo(ReadOnlyCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts new file mode 100644 index 000000000000..c3429f436503 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts @@ -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', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap index f264dde07c59..604f86866d56 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap @@ -11,7 +11,6 @@ exports[`RuleSwitch renders correctly against snapshot 1`] = ` ; 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} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 12bbdbdfff3e..ce91e15cdcf0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -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 => ({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 848b17aadbff..9a0f41bbd8c5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -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.defineRule); const defineRuleRef = useRef(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 ; + } else if (userHasNoPermissions) { + return ; + } const setStepData = useCallback( (step: RuleStep, data: unknown, isValid: boolean) => { @@ -216,7 +236,7 @@ export const CreateRuleComponent = React.memo(() => { @@ -242,7 +262,7 @@ export const CreateRuleComponent = React.memo(() => { setHeightAccordion(height)} @@ -273,7 +293,7 @@ export const CreateRuleComponent = React.memo(() => { @@ -303,7 +323,7 @@ export const CreateRuleComponent = React.memo(() => { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 9b6998ab4a13..679f42f02519 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -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( - ({ 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 ? : rule.name; + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } + + const title = isLoading === true || rule === null ? : rule.name; const subTitle = useMemo( () => - loading === true || rule === null ? ( + isLoading === true || rule === null ? ( ) : ( [ @@ -118,7 +136,7 @@ const RuleDetailsComponent = memo( ), ] ), - [loading, rule] + [isLoading, rule] ); const signalDefaultFilters = useMemo( @@ -140,6 +158,8 @@ const RuleDetailsComponent = memo( return ( <> + {hasIndexWrite != null && !hasIndexWrite && } + {userHasNoPermissions && } {({ indicesExist, indexPattern }) => { return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( @@ -175,6 +195,7 @@ const RuleDetailsComponent = memo( @@ -186,7 +207,7 @@ const RuleDetailsComponent = memo( {ruleI18n.EDIT_RULE_SETTINGS} @@ -200,7 +221,7 @@ const RuleDetailsComponent = memo( - + {defineRuleData != null && ( ( - + {aboutRuleData != null && ( ( - + {scheduleRuleData != null && ( ( {ruleId != null && ( )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 10b7f0e832f1..e583461f5243 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -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 ; + } else if (userHasNoPermissions) { + return ; + } + const [initForm, setInitForm] = useState(false); const [myAboutRuleForm, setMyAboutRuleForm] = useState({ data: null, @@ -89,7 +109,7 @@ export const EditRuleComponent = memo(() => { content: ( <> - + {myDefineRuleForm.data != null && ( { content: ( <> - + {myAboutRuleForm.data != null && ( { content: ( <> - + {myScheduleRuleForm.data != null && ( { ], [ loading, + initLoading, isLoading, myAboutRuleForm, myDefineRuleForm, @@ -310,7 +331,13 @@ export const EditRuleComponent = memo(() => { - + {i18n.SAVE_CHANGES} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 47b5c1051bcf..cc0882dd7e42 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -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); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx index ef67f0a7d22c..dd46b33ca725 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx @@ -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 ; + } + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; const lastCompletedRun = undefined; return ( <> + {userHasNoPermissions && } setShowImportModal(false)} @@ -56,6 +76,7 @@ export const RulesComponent = React.memo(() => { { setShowImportModal(true); }} @@ -63,20 +84,23 @@ export const RulesComponent = React.memo(() => { {i18n.IMPORT_RULE} - {i18n.ADD_NEW_RULE} - - +
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index ec4206623bad..541b058951be 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -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[]; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts new file mode 100644 index 000000000000..cb358c15e5fa --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts @@ -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'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts index ff65caa59a86..705f542b5012 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts @@ -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, + callWithRequest: CallWithRequest< + { index: string; size: number; terminate_after: number; allow_no_indices: boolean }, + {}, + { _shards: { total: number } } + >, index: string ): Promise => { - 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; + } + } }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 094449a5f61a..10dc14f7ed61 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -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); diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 8a47aa2a2708..90ae79ef19d5 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -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'],