[Security Solution][Endpoint] Refresh action and comments on the Case Details view when Isolation actions are created (#103160)

* Expose new Prop from `CaseComponent` that exposes data refresh callbacks
* Refresh case actions and comments if isolation was created successfully
This commit is contained in:
Paul Tavares 2021-06-28 10:39:23 -04:00 committed by GitHub
parent 648841429c
commit 91fc3cc2b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 234 additions and 52 deletions

View file

@ -23,6 +23,23 @@ export type StatusAllType = typeof StatusAll;
export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType;
/**
* The type for the `refreshRef` prop (a `React.Ref`) defined by the `CaseViewComponentProps`.
*
* @example
* const refreshRef = useRef<CaseViewRefreshPropInterface>(null);
* return <CaseComponent refreshRef={refreshRef} ...otherProps>
*/
export type CaseViewRefreshPropInterface = null | {
/**
* Refreshes the all of the user actions/comments in the view's timeline
* (note: this also triggers a silent `refreshCase()`)
*/
refreshUserActionsAndComments: () => Promise<void>;
/** Refreshes the Case information only */
refreshCase: () => Promise<void>;
};
export type Comment = CommentRequest & {
associationType: AssociationType;
id: string;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useState, useRef, MutableRefObject } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
import {
@ -16,11 +16,19 @@ import {
EuiHorizontalRule,
} from '@elastic/eui';
import { CaseStatuses, CaseAttributes, CaseType, Case, CaseConnector, Ecs } from '../../../common';
import {
CaseStatuses,
CaseAttributes,
CaseType,
Case,
CaseConnector,
Ecs,
CaseViewRefreshPropInterface,
} from '../../../common';
import { HeaderPage } from '../header_page';
import { EditableTitle } from '../header_page/editable_title';
import { TagList } from '../tag_list';
import { useGetCase } from '../../containers/use_get_case';
import { UseGetCase, useGetCase } from '../../containers/use_get_case';
import { UserActionTree } from '../user_action_tree';
import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
@ -42,6 +50,7 @@ import { getConnectorById } from '../utils';
import { DoesNotExist } from './does_not_exist';
const gutterTimeline = '70px'; // seems to be a timeline reference from the original file
export interface CaseViewComponentProps {
allCasesNavigation: CasesNavigation;
caseDetailsNavigation: CasesNavigation;
@ -54,12 +63,18 @@ export interface CaseViewComponentProps {
subCaseId?: string;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
userCanCrud: boolean;
/**
* A React `Ref` that Exposes data refresh callbacks.
* **NOTE**: Do not hold on to the `.current` object, as it could become stale
*/
refreshRef?: MutableRefObject<CaseViewRefreshPropInterface>;
}
export interface CaseViewProps extends CaseViewComponentProps {
onCaseDataSuccess?: (data: Case) => void;
timelineIntegration?: CasesTimelineIntegration;
}
export interface OnUpdateFields {
key: keyof Case;
value: Case[keyof Case];
@ -78,13 +93,14 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)`
const MyEuiHorizontalRule = styled(EuiHorizontalRule)`
margin-left: 48px;
&.euiHorizontalRule--full {
width: calc(100% - 48px);
}
`;
export interface CaseComponentProps extends CaseViewComponentProps {
fetchCase: () => void;
fetchCase: UseGetCase['fetchCase'];
caseData: Case;
updateCase: (newCase: Case) => void;
}
@ -105,6 +121,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
updateCase,
useFetchAlertData,
userCanCrud,
refreshRef,
}) => {
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);
@ -124,6 +141,51 @@ export const CaseComponent = React.memo<CaseComponentProps>(
subCaseId,
});
// Set `refreshRef` if needed
useEffect(() => {
let isStale = false;
if (refreshRef) {
refreshRef.current = {
refreshCase: async () => {
// Do nothing if component (or instance of this render cycle) is stale
if (isStale) {
return;
}
await fetchCase();
},
refreshUserActionsAndComments: async () => {
// Do nothing if component (or instance of this render cycle) is stale
// -- OR --
// it is already loading
if (isStale || isLoadingUserActions) {
return;
}
await Promise.all([
fetchCase(true),
fetchCaseUserActions(caseId, caseData.connector.id, subCaseId),
]);
},
};
return () => {
isStale = true;
refreshRef.current = null;
};
}
}, [
caseData.connector.id,
caseId,
fetchCase,
fetchCaseUserActions,
isLoadingUserActions,
refreshRef,
subCaseId,
updateCase,
]);
// Update Fields
const onUpdateField = useCallback(
({ key, value, onSuccess, onError }: OnUpdateFields) => {
@ -491,6 +553,7 @@ export const CaseView = React.memo(
timelineIntegration,
useFetchAlertData,
userCanCrud,
refreshRef,
}: CaseViewProps) => {
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
if (isError) {
@ -528,6 +591,7 @@ export const CaseView = React.memo(
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
refreshRef={refreshRef}
/>
</OwnerProvider>
</CasesTimelineIntegrationProvider>

View file

@ -89,6 +89,19 @@ describe('useGetCase', () => {
});
});
it('set isLoading to false when refetching case "silent"ly', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCase>(() =>
useGetCase(basicCase.id)
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.fetchCase(true);
expect(result.current.isLoading).toBe(false);
});
});
it('unhappy path', async () => {
const spyOnGetCase = jest.spyOn(api, 'getCase');
spyOnGetCase.mockImplementation(() => {

View file

@ -19,7 +19,7 @@ interface CaseState {
}
type Action =
| { type: 'FETCH_INIT' }
| { type: 'FETCH_INIT'; payload: { silent: boolean } }
| { type: 'FETCH_SUCCESS'; payload: Case }
| { type: 'FETCH_FAILURE' }
| { type: 'UPDATE_CASE'; payload: Case };
@ -29,7 +29,10 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
case 'FETCH_INIT':
return {
...state,
isLoading: true,
// If doing a silent fetch, then don't set `isLoading`. This helps
// with preventing screen flashing when wanting to refresh the actions
// and comments
isLoading: !action.payload?.silent,
isError: false,
};
case 'FETCH_SUCCESS':
@ -56,7 +59,11 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
};
export interface UseGetCase extends CaseState {
fetchCase: () => void;
/**
* @param [silent] When set to `true`, the `isLoading` property will not be set to `true`
* while doing the API call
*/
fetchCase: (silent?: boolean) => Promise<void>;
updateCase: (newCase: Case) => void;
}
@ -74,33 +81,35 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
dispatch({ type: 'UPDATE_CASE', payload: newCase });
}, []);
const callFetch = useCallback(async () => {
try {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT' });
const callFetch = useCallback(
async (silent: boolean = false) => {
try {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT', payload: { silent } });
const response = await (subCaseId
? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal)
: getCase(caseId, true, abortCtrlRef.current.signal));
const response = await (subCaseId
? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal)
: getCase(caseId, true, abortCtrlRef.current.signal));
if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{ title: i18n.ERROR_TITLE }
);
if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{ title: i18n.ERROR_TITLE }
);
}
dispatch({ type: 'FETCH_FAILURE' });
}
dispatch({ type: 'FETCH_FAILURE' });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseId, subCaseId]);
},
[caseId, subCaseId, toasts]
);
useEffect(() => {
callFetch();

View file

@ -51,7 +51,11 @@ export const initialData: CaseUserActionsState = {
};
export interface UseGetCaseUserActions extends CaseUserActionsState {
fetchCaseUserActions: (caseId: string, caseConnectorId: string, subCaseId?: string) => void;
fetchCaseUserActions: (
caseId: string,
caseConnectorId: string,
subCaseId?: string
) => Promise<void>;
}
const getExternalService = (value: string): CaseExternalService | null =>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { SearchResponse } from 'elasticsearch';
import { isEmpty } from 'lodash';
@ -19,7 +19,7 @@ import {
useFormatUrl,
} from '../../../common/components/link_to';
import { Ecs } from '../../../../common/ecs';
import { Case } from '../../../../../cases/common';
import { Case, CaseViewRefreshPropInterface } from '../../../../../cases/common';
import { TimelineId } from '../../../../common/types/timeline';
import { SecurityPageName } from '../../../app/types';
import { KibanaServices, useKibana } from '../../../common/lib/kibana';
@ -38,6 +38,7 @@ import { SEND_ALERT_TO_TIMELINE } from './translations';
import { useInsertTimeline } from '../use_insert_timeline';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline';
import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context';
interface Props {
caseId: string;
@ -176,9 +177,13 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
})
);
}, [dispatch]);
const refreshRef = useRef<CaseViewRefreshPropInterface>(null);
return (
<>
<CaseDetailsRefreshContext.Provider value={refreshRef}>
{casesUi.getCaseView({
refreshRef,
allCasesNavigation: {
href: formattedAllCasesLink,
onClick: async (e) => {
@ -247,7 +252,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
userCanCrud,
})}
<SpyRoute state={spyState} pageName={SecurityPageName.case} />
</>
</CaseDetailsRefreshContext.Provider>
);
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { MutableRefObject, useContext } from 'react';
import { CaseViewRefreshPropInterface } from '../../../../../../cases/common';
/**
* React Context that can hold the `Ref` that is created an passed to `CaseViewProps['refreshRef`]`, enabling
* child components to trigger a refresh of a case.
*/
export const CaseDetailsRefreshContext = React.createContext<MutableRefObject<CaseViewRefreshPropInterface> | null>(
null
);
/**
* Returns the closes CaseDetails Refresh interface if any. Used in conjuction with `CaseDetailsRefreshContext` component
*
* @example
* // Higher-order component
* const refreshRef = useRef<CaseViewRefreshPropInterface>(null);
* return <CaseDetailsRefreshContext.Provider value={refreshRef}>....</CaseDetailsRefreshContext.Provider>
*
* // Now, use the hook from a hild component that was rendered inside of `<CaseDetailsRefreshContext.Provider>`
* const caseDetailsRefresh = useWithCaseDetailsRefresh();
* ...
* if (caseDetailsRefresh) {
* caseDetailsRefresh.refreshUserActionsAndComments();
* }
*/
export const useWithCaseDetailsRefresh = (): Readonly<CaseViewRefreshPropInterface> | undefined => {
return useContext(CaseDetailsRefreshContext)?.current;
};

View file

@ -20,10 +20,12 @@ export const HostIsolationPanel = React.memo(
({
details,
cancelCallback,
successCallback,
isolateAction,
}: {
details: Maybe<TimelineEventsDetailsItem[]>;
cancelCallback: () => void;
successCallback?: () => void;
isolateAction: string;
}) => {
const endpointId = useMemo(() => {
@ -92,6 +94,7 @@ export const HostIsolationPanel = React.memo(
cases={associatedCases}
casesInfo={casesInfo}
cancelCallback={cancelCallback}
successCallback={successCallback}
/>
) : (
<UnisolateHost
@ -100,6 +103,7 @@ export const HostIsolationPanel = React.memo(
cases={associatedCases}
casesInfo={casesInfo}
cancelCallback={cancelCallback}
successCallback={successCallback}
/>
);
}

View file

@ -24,12 +24,14 @@ export const IsolateHost = React.memo(
cases,
casesInfo,
cancelCallback,
successCallback,
}: {
endpointId: string;
hostName: string;
cases: ReactNode;
casesInfo: CasesFromAlertsResponse;
cancelCallback: () => void;
successCallback?: () => void;
}) => {
const [comment, setComment] = useState('');
const [isIsolated, setIsIsolated] = useState(false);
@ -43,7 +45,11 @@ export const IsolateHost = React.memo(
const confirmHostIsolation = useCallback(async () => {
const hostIsolated = await isolateHost();
setIsIsolated(hostIsolated);
}, [isolateHost]);
if (hostIsolated && successCallback) {
successCallback();
}
}, [isolateHost, successCallback]);
const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]);

View file

@ -24,12 +24,14 @@ export const UnisolateHost = React.memo(
cases,
casesInfo,
cancelCallback,
successCallback,
}: {
endpointId: string;
hostName: string;
cases: ReactNode;
casesInfo: CasesFromAlertsResponse;
cancelCallback: () => void;
successCallback?: () => void;
}) => {
const [comment, setComment] = useState('');
const [isUnIsolated, setIsUnIsolated] = useState(false);
@ -41,9 +43,13 @@ export const UnisolateHost = React.memo(
const { loading, unIsolateHost } = useHostUnisolation({ endpointId, comment, caseIds });
const confirmHostUnIsolation = useCallback(async () => {
const hostIsolated = await unIsolateHost();
setIsUnIsolated(hostIsolated);
}, [unIsolateHost]);
const hostUnIsolated = await unIsolateHost();
setIsUnIsolated(hostUnIsolated);
if (hostUnIsolated && successCallback) {
successCallback();
}
}, [successCallback, unIsolateHost]);
const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]);

View file

@ -12,6 +12,7 @@ import { createHostIsolation } from './api';
interface HostIsolationStatus {
loading: boolean;
/** Boolean return will indicate if isolation action was created successful */
isolateHost: () => Promise<boolean>;
}

View file

@ -33,6 +33,7 @@ import {
import { ALERT_DETAILS } from './translations';
import { useIsolationPrivileges } from '../../../../common/hooks/endpoint/use_isolate_privileges';
import { endpointAlertCheck } from '../../../../common/utils/endpoint_alert_check';
import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context';
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
.euiFlyoutBody__overflow {
@ -121,6 +122,15 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
);
}, [showAlertDetails, isolateAction]);
const caseDetailsRefresh = useWithCaseDetailsRefresh();
const handleIsolationActionSuccess = useCallback(() => {
// If a case details refresh ref is defined, then refresh actions and comments
if (caseDetailsRefresh) {
caseDetailsRefresh.refreshUserActionsAndComments();
}
}, [caseDetailsRefresh]);
if (!expandedEvent?.eventId) {
return null;
}
@ -139,6 +149,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
<HostIsolationPanel
details={detailsData}
cancelCallback={showAlertDetails}
successCallback={handleIsolationActionSuccess}
isolateAction={isolateAction}
/>
) : (

View file

@ -61,6 +61,7 @@ export const isolationRequestHandler = function (
TypeOf<typeof HostIsolationRequestSchema.body>,
SecuritySolutionRequestHandlerContext
> {
// eslint-disable-next-line complexity
return async (context, req, res) => {
if (
(!req.body.agent_ids || req.body.agent_ids.length === 0) &&
@ -100,14 +101,14 @@ export const isolationRequestHandler = function (
}
agentIDs = [...new Set(agentIDs)]; // dedupe
const casesClient = await endpointContext.service.getCasesClient(req);
// convert any alert IDs into cases
let caseIDs: string[] = req.body.case_ids?.slice() || [];
if (req.body.alert_ids && req.body.alert_ids.length > 0) {
const newIDs: string[][] = await Promise.all(
req.body.alert_ids.map(async (a: string) => {
const cases: CasesByAlertId = await (
await endpointContext.service.getCasesClient(req)
).cases.getCasesByAlertID({
const cases: CasesByAlertId = await casesClient.cases.getCasesByAlertID({
alertID: a,
options: { owner: APP_ID },
});
@ -167,16 +168,21 @@ export const isolationRequestHandler = function (
commentLines.push(`\n\nWith Comment:\n> ${req.body.comment}`);
}
caseIDs.forEach(async (caseId) => {
(await endpointContext.service.getCasesClient(req)).attachments.add({
caseId,
comment: {
comment: commentLines.join('\n'),
type: CommentType.user,
owner: APP_ID,
},
});
});
// Update all cases with a comment
if (caseIDs.length > 0) {
await Promise.all(
caseIDs.map((caseId) =>
casesClient.attachments.add({
caseId,
comment: {
comment: commentLines.join('\n'),
type: CommentType.user,
owner: APP_ID,
},
})
)
);
}
return res.ok({
body: {