mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Cloud Posture] Use ES PIT in findings page (#132503)
This commit is contained in:
parent
ec7f3d703d
commit
cbca99d860
25 changed files with 570 additions and 119 deletions
|
@ -7,10 +7,10 @@
|
|||
|
||||
export const INFO_ROUTE_PATH = '/internal/cloud_security_posture/setup_status';
|
||||
export const STATS_ROUTE_PATH = '/internal/cloud_security_posture/stats';
|
||||
export const FINDINGS_ROUTE_PATH = '/internal/cloud_security_posture/findings';
|
||||
export const BENCHMARKS_ROUTE_PATH = '/internal/cloud_security_posture/benchmarks';
|
||||
export const UPDATE_RULES_CONFIG_ROUTE_PATH =
|
||||
'/internal/cloud_security_posture/update_rules_config';
|
||||
export const ES_PIT_ROUTE_PATH = '/internal/cloud_security_posture/es_pit';
|
||||
|
||||
export const CLOUD_SECURITY_POSTURE_PACKAGE_NAME = 'cloud_security_posture';
|
||||
|
||||
|
|
|
@ -18,7 +18,9 @@ import { UnknownRoute } from '../components/unknown_route';
|
|||
import type { CspClientPluginStartDeps } from '../types';
|
||||
import { pageToComponentMapping } from './constants';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
export interface CspAppDeps {
|
||||
core: CoreStart;
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { useQuery } from 'react-query';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../common/constants';
|
||||
import { CspClientPluginStartDeps } from '../../types';
|
||||
|
||||
|
@ -18,8 +19,14 @@ export const useLatestFindingsDataView = () => {
|
|||
data: { dataViews },
|
||||
} = useKibana<CspClientPluginStartDeps>().services;
|
||||
|
||||
// TODO: use `dataViews.get(ID)`
|
||||
const findDataView = async () => (await dataViews.find(CSP_LATEST_FINDINGS_DATA_VIEW))?.[0];
|
||||
const findDataView = async (): Promise<DataView> => {
|
||||
const dataView = (await dataViews.find(CSP_LATEST_FINDINGS_DATA_VIEW))?.[0];
|
||||
if (!dataView) {
|
||||
throw new Error('Findings data view not found');
|
||||
}
|
||||
|
||||
return useQuery(['latest_findings_dataview'], findDataView);
|
||||
return dataView;
|
||||
};
|
||||
|
||||
return useQuery(['latest_findings_data_view'], findDataView);
|
||||
};
|
||||
|
|
|
@ -5,17 +5,28 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, useEuiTheme } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
export const CspLoadingState: React.FunctionComponent<{ ['data-test-subj']?: string }> = ({
|
||||
children,
|
||||
...rest
|
||||
}) => (
|
||||
<EuiFlexGroup direction="column" alignItems="center" data-test-subj={rest['data-test-subj']}>
|
||||
<EuiFlexItem>
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{children}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
css={css`
|
||||
padding: ${euiTheme.size.l};
|
||||
`}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
data-test-subj={rest['data-test-subj']}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{children}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -132,6 +132,22 @@ describe('<CspPageTemplate />', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('renders default loading text when query is idle', () => {
|
||||
const query = createReactQueryResponse({
|
||||
status: 'idle',
|
||||
}) as unknown as UseQueryResult;
|
||||
|
||||
const children = chance.sentence();
|
||||
renderCspPageTemplate({ children, query });
|
||||
|
||||
expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument();
|
||||
packageNotInstalledUniqueTexts.forEach((text) =>
|
||||
expect(screen.queryByText(text)).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders default error texts when query isError', () => {
|
||||
const error = chance.sentence();
|
||||
const message = chance.sentence();
|
||||
|
|
|
@ -188,7 +188,9 @@ export const CspPageTemplate = <TData, TError>({
|
|||
};
|
||||
|
||||
const render = () => {
|
||||
if (query?.isLoading || cisKubernetesPackageInfo.isLoading) return loadingRender();
|
||||
if (query?.isLoading || query?.isIdle || cisKubernetesPackageInfo.isLoading) {
|
||||
return loadingRender();
|
||||
}
|
||||
if (query?.isError) return errorRender(query.error);
|
||||
if (query?.isSuccess) return children;
|
||||
|
||||
|
|
|
@ -5,5 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export const RULE_PASSED = `passed`;
|
||||
export const RULE_FAILED = `failed`;
|
||||
export const FINDINGS_PIT_KEEP_ALIVE = '2m';
|
||||
// Set to half of the PIT keep alive to make sure we keep the PIT window open as long as the components are mounted
|
||||
export const FINDINGS_REFETCH_INTERVAL_MS = 1000 * 60; // One minute
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 { createContext, type MutableRefObject } from 'react';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
|
||||
interface FindingsEsPitContextValue {
|
||||
setPitId(newPitId: string): void;
|
||||
pitIdRef: MutableRefObject<string>;
|
||||
pitQuery: UseQueryResult<string>;
|
||||
}
|
||||
|
||||
// Default value should never be used, it can not be instantiated statically. Always wrap in a provider with a value
|
||||
export const FindingsEsPitContext = createContext<FindingsEsPitContextValue>(
|
||||
{} as FindingsEsPitContextValue
|
||||
);
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { CSP_LATEST_FINDINGS_DATA_VIEW, ES_PIT_ROUTE_PATH } from '../../../../common/constants';
|
||||
import { useKibana } from '../../../common/hooks/use_kibana';
|
||||
import { FINDINGS_PIT_KEEP_ALIVE } from '../constants';
|
||||
|
||||
export const useFindingsEsPit = (queryKey: string) => {
|
||||
// Using a reference for the PIT ID to avoid re-rendering when it changes
|
||||
const pitIdRef = useRef<string>();
|
||||
// Using this state as an internal control to ensure we run the query to open the PIT once and only once
|
||||
const [isPitIdSet, setPitIdSet] = useState(false);
|
||||
const setPitId = useCallback(
|
||||
(newPitId: string) => {
|
||||
pitIdRef.current = newPitId;
|
||||
setPitIdSet(true);
|
||||
},
|
||||
[pitIdRef, setPitIdSet]
|
||||
);
|
||||
|
||||
const { http } = useKibana().services;
|
||||
const pitQuery = useQuery(
|
||||
['findingsPitQuery', queryKey],
|
||||
() =>
|
||||
http.post<string>(ES_PIT_ROUTE_PATH, {
|
||||
query: { index_name: CSP_LATEST_FINDINGS_DATA_VIEW, keep_alive: FINDINGS_PIT_KEEP_ALIVE },
|
||||
}),
|
||||
{
|
||||
enabled: !isPitIdSet,
|
||||
onSuccess: (pitId) => {
|
||||
setPitId(pitId);
|
||||
},
|
||||
cacheTime: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return { pitIdRef, setPitId, pitQuery };
|
||||
};
|
|
@ -5,7 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import { Redirect, Switch, Route, useLocation } from 'react-router-dom';
|
||||
import { useFindingsEsPit } from './es_pit/use_findings_es_pit';
|
||||
import { FindingsEsPitContext } from './es_pit/findings_es_pit_context';
|
||||
import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view';
|
||||
import { allNavigationItems, findingsNavigation } from '../../common/navigation/constants';
|
||||
import { CspPageTemplate } from '../../components/csp_page_template';
|
||||
|
@ -15,37 +18,51 @@ import { LatestFindingsContainer } from './latest_findings/latest_findings_conta
|
|||
export const Findings = () => {
|
||||
const location = useLocation();
|
||||
const dataViewQuery = useLatestFindingsDataView();
|
||||
// TODO: Consider splitting the PIT window so that each "group by" view has its own PIT
|
||||
const { pitQuery, pitIdRef, setPitId } = useFindingsEsPit('findings');
|
||||
|
||||
if (!dataViewQuery.data) return <CspPageTemplate paddingSize="none" query={dataViewQuery} />;
|
||||
let queryForPageTemplate: UseQueryResult = dataViewQuery;
|
||||
if (pitQuery.isError || pitQuery.isLoading || pitQuery.isIdle) {
|
||||
queryForPageTemplate = pitQuery;
|
||||
}
|
||||
|
||||
return (
|
||||
<CspPageTemplate paddingSize="none" query={dataViewQuery}>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={allNavigationItems.findings.path}
|
||||
component={() => (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: findingsNavigation.findings_default.path,
|
||||
search: location.search,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_default.path}
|
||||
render={() => <LatestFindingsContainer dataView={dataViewQuery.data} />}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_by_resource.path}
|
||||
render={() => <FindingsByResourceContainer dataView={dataViewQuery.data} />}
|
||||
/>
|
||||
<Route
|
||||
path={'*'}
|
||||
component={() => <Redirect to={findingsNavigation.findings_default.path} />}
|
||||
/>
|
||||
</Switch>
|
||||
<CspPageTemplate paddingSize="none" query={queryForPageTemplate}>
|
||||
<FindingsEsPitContext.Provider
|
||||
value={{
|
||||
pitQuery,
|
||||
// Asserting the ref as a string value since at this point the query was necessarily successful
|
||||
pitIdRef: pitIdRef as React.MutableRefObject<string>,
|
||||
setPitId,
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={allNavigationItems.findings.path}
|
||||
component={() => (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: findingsNavigation.findings_default.path,
|
||||
search: location.search,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_default.path}
|
||||
render={() => <LatestFindingsContainer dataView={dataViewQuery.data!} />}
|
||||
/>
|
||||
<Route
|
||||
path={findingsNavigation.findings_by_resource.path}
|
||||
render={() => <FindingsByResourceContainer dataView={dataViewQuery.data!} />}
|
||||
/>
|
||||
<Route
|
||||
path={'*'}
|
||||
component={() => <Redirect to={findingsNavigation.findings_default.path} />}
|
||||
/>
|
||||
</Switch>
|
||||
</FindingsEsPitContext.Provider>
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import { createReactQueryResponse } from '../../../test/fixtures/react_query';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { LatestFindingsContainer, getDefaultQuery } from './latest_findings_container';
|
||||
|
@ -19,6 +21,7 @@ import { RisonObject } from 'rison-node';
|
|||
import { buildEsQuery } from '@kbn/es-query';
|
||||
import { getFindingsCountAggQuery } from '../use_findings_count';
|
||||
import { getPaginationQuery } from '../utils';
|
||||
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
|
||||
|
||||
jest.mock('../../../common/api/use_latest_findings_data_view');
|
||||
jest.mock('../../../common/api/use_cis_kubernetes_integration');
|
||||
|
@ -47,6 +50,13 @@ describe('<LatestFindingsContainer />', () => {
|
|||
search: encodeQuery(query as unknown as RisonObject),
|
||||
});
|
||||
|
||||
const setPitId = jest.fn();
|
||||
const pitIdRef = { current: '' };
|
||||
const pitQuery = createReactQueryResponse({
|
||||
status: 'success',
|
||||
data: '',
|
||||
}) as UseQueryResult<string>;
|
||||
|
||||
render(
|
||||
<TestProvider
|
||||
deps={{
|
||||
|
@ -54,13 +64,15 @@ describe('<LatestFindingsContainer />', () => {
|
|||
unifiedSearch: unifiedSearchPluginMock.createStartContract(),
|
||||
}}
|
||||
>
|
||||
<LatestFindingsContainer dataView={dataView} />
|
||||
<FindingsEsPitContext.Provider value={{ setPitId, pitIdRef, pitQuery }}>
|
||||
<LatestFindingsContainer dataView={dataView} />
|
||||
</FindingsEsPitContext.Provider>
|
||||
</TestProvider>
|
||||
);
|
||||
|
||||
const baseQuery = {
|
||||
index: dataView.title,
|
||||
query: buildEsQuery(dataView, query.query, query.filters),
|
||||
pitId: pitIdRef.current,
|
||||
};
|
||||
|
||||
expect(dataMock.search.search).toHaveBeenNthCalledWith(1, {
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { number } from 'io-ts';
|
||||
import type { FindingsBaseProps } from '../types';
|
||||
import { FindingsTable } from './latest_findings_table';
|
||||
import { FindingsSearchBar } from '../layout/findings_search_bar';
|
||||
import * as TEST_SUBJECTS from '../test_subjects';
|
||||
|
@ -32,7 +32,7 @@ export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQue
|
|||
pageSize: 10,
|
||||
});
|
||||
|
||||
export const LatestFindingsContainer = ({ dataView }: { dataView: DataView }) => {
|
||||
export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => {
|
||||
useCspBreadcrumbs([findingsNavigation.findings_default]);
|
||||
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { useContext } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { number } from 'io-ts';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
@ -11,11 +12,14 @@ import type { IEsSearchResponse } from '@kbn/data-plugin/common';
|
|||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { Criteria, Pagination } from '@elastic/eui';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
|
||||
import { extractErrorMessage } from '../../../../common/utils/helpers';
|
||||
import * as TEXT from '../translations';
|
||||
import type { CspFindingsQueryData } from '../types';
|
||||
import type { CspFinding, FindingsQueryResult } from '../types';
|
||||
import { useKibana } from '../../../common/hooks/use_kibana';
|
||||
import type { FindingsBaseEsQuery } from '../types';
|
||||
import { FINDINGS_REFETCH_INTERVAL_MS } from '../constants';
|
||||
|
||||
interface UseFindingsOptions extends FindingsBaseEsQuery {
|
||||
from: NonNullable<estypes.SearchRequest['from']>;
|
||||
|
@ -31,12 +35,7 @@ export interface FindingsGroupByNoneQuery {
|
|||
sort: Sort;
|
||||
}
|
||||
|
||||
interface CspFindingsData {
|
||||
page: CspFinding[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type CspFindingsResult = FindingsQueryResult<CspFindingsData | undefined, unknown>;
|
||||
export type CspFindingsResult = FindingsQueryResult<CspFindingsQueryData | undefined, unknown>;
|
||||
|
||||
const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set([
|
||||
'@timestamp',
|
||||
|
@ -57,35 +56,55 @@ export const showErrorToast = (
|
|||
else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED));
|
||||
};
|
||||
|
||||
export const getFindingsQuery = ({ index, query, size, from, sort }: UseFindingsOptions) => ({
|
||||
index,
|
||||
export const getFindingsQuery = ({
|
||||
query,
|
||||
size,
|
||||
from,
|
||||
sort,
|
||||
pitId,
|
||||
}: UseFindingsOptions & { pitId: string }) => ({
|
||||
query,
|
||||
size,
|
||||
from,
|
||||
sort: [{ [getSortKey(sort.field)]: sort.direction }],
|
||||
pit: { id: pitId },
|
||||
ignore_unavailable: false,
|
||||
});
|
||||
|
||||
export const useLatestFindings = ({ index, query, sort, from, size }: UseFindingsOptions) => {
|
||||
export const useLatestFindings = ({ query, sort, from, size }: UseFindingsOptions) => {
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
|
||||
const pitId = pitIdRef.current;
|
||||
|
||||
return useQuery(
|
||||
['csp_findings', { index, query, sort, from, size }],
|
||||
return useQuery<
|
||||
IEsSearchResponse<CspFinding>,
|
||||
unknown,
|
||||
CspFindingsQueryData & { newPitId: string }
|
||||
>(
|
||||
['csp_findings', { query, sort, from, size, pitId }],
|
||||
() =>
|
||||
lastValueFrom<IEsSearchResponse<CspFinding>>(
|
||||
data.search.search({
|
||||
params: getFindingsQuery({ index, query, sort, from, size }),
|
||||
params: getFindingsQuery({ query, sort, from, size, pitId }),
|
||||
})
|
||||
),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
select: ({ rawResponse: { hits } }) => ({
|
||||
select: ({ rawResponse: { hits, pit_id: newPitId } }) => ({
|
||||
page: hits.hits.map((hit) => hit._source!),
|
||||
total: number.is(hits.total) ? hits.total : 0,
|
||||
newPitId: newPitId!,
|
||||
}),
|
||||
onError: (err) => showErrorToast(toasts, err),
|
||||
onSuccess: ({ newPitId }) => {
|
||||
setPitId(newPitId);
|
||||
},
|
||||
// Refetching on an interval to ensure the PIT window stays open
|
||||
refetchInterval: FINDINGS_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,13 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { FindingsSearchBar } from '../layout/findings_search_bar';
|
||||
import * as TEST_SUBJECTS from '../test_subjects';
|
||||
import { useUrlQuery } from '../../../common/hooks/use_url_query';
|
||||
import type { FindingsBaseURLQuery } from '../types';
|
||||
import type { FindingsBaseProps, FindingsBaseURLQuery } from '../types';
|
||||
import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource';
|
||||
import { FindingsByResourceTable } from './findings_by_resource_table';
|
||||
import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../utils';
|
||||
|
@ -28,7 +27,7 @@ const getDefaultQuery = (): FindingsBaseURLQuery & FindingsByResourceQuery => ({
|
|||
pageSize: 10,
|
||||
});
|
||||
|
||||
export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => (
|
||||
export const FindingsByResourceContainer = ({ dataView }: FindingsBaseProps) => (
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
|
@ -42,7 +41,7 @@ export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }
|
|||
</Switch>
|
||||
);
|
||||
|
||||
const LatestFindingsByResource = ({ dataView }: { dataView: DataView }) => {
|
||||
const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => {
|
||||
useCspBreadcrumbs([findingsNavigation.findings_by_resource]);
|
||||
const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery);
|
||||
const findingsGroupByResource = useFindingsByResource({
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
import React from 'react';
|
||||
import { EuiSpacer, EuiButtonEmpty } from '@elastic/eui';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useEuiTheme } from '@elastic/eui';
|
||||
|
@ -17,7 +16,7 @@ import { useCspBreadcrumbs } from '../../../../common/navigation/use_csp_breadcr
|
|||
import { findingsNavigation } from '../../../../common/navigation/constants';
|
||||
import { ResourceFindingsQuery, useResourceFindings } from './use_resource_findings';
|
||||
import { useUrlQuery } from '../../../../common/hooks/use_url_query';
|
||||
import type { FindingsBaseURLQuery } from '../../types';
|
||||
import type { FindingsBaseProps, FindingsBaseURLQuery } from '../../types';
|
||||
import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../../utils';
|
||||
import { ResourceFindingsTable } from './resource_findings_table';
|
||||
import { FindingsSearchBar } from '../../layout/findings_search_bar';
|
||||
|
@ -40,7 +39,7 @@ const BackToResourcesButton = () => (
|
|||
</Link>
|
||||
);
|
||||
|
||||
export const ResourceFindings = ({ dataView }: { dataView: DataView }) => {
|
||||
export const ResourceFindings = ({ dataView }: FindingsBaseProps) => {
|
||||
useCspBreadcrumbs([findingsNavigation.findings_default]);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const params = useParams<{ resourceId: string }>();
|
||||
|
|
|
@ -9,8 +9,12 @@ import { lastValueFrom } from 'rxjs';
|
|||
import { IEsSearchResponse } from '@kbn/data-plugin/common';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { Pagination } from '@elastic/eui';
|
||||
import { useContext } from 'react';
|
||||
import { FindingsEsPitContext } from '../../es_pit/findings_es_pit_context';
|
||||
import { FINDINGS_REFETCH_INTERVAL_MS } from '../../constants';
|
||||
import { useKibana } from '../../../../common/hooks/use_kibana';
|
||||
import { showErrorToast } from '../../latest_findings/use_latest_findings';
|
||||
import type { CspFindingsQueryData } from '../../types';
|
||||
import type { CspFinding, FindingsBaseEsQuery, FindingsQueryResult } from '../../types';
|
||||
|
||||
interface UseResourceFindingsOptions extends FindingsBaseEsQuery {
|
||||
|
@ -24,19 +28,15 @@ export interface ResourceFindingsQuery {
|
|||
pageSize: Pagination['pageSize'];
|
||||
}
|
||||
|
||||
export type ResourceFindingsResult = FindingsQueryResult<
|
||||
ReturnType<typeof useResourceFindings>['data'] | undefined,
|
||||
unknown
|
||||
>;
|
||||
export type ResourceFindingsResult = FindingsQueryResult<CspFindingsQueryData | undefined, unknown>;
|
||||
|
||||
const getResourceFindingsQuery = ({
|
||||
index,
|
||||
query,
|
||||
resourceId,
|
||||
from,
|
||||
size,
|
||||
}: UseResourceFindingsOptions): estypes.SearchRequest => ({
|
||||
index,
|
||||
pitId,
|
||||
}: UseResourceFindingsOptions & { pitId: string }): estypes.SearchRequest => ({
|
||||
from,
|
||||
size,
|
||||
body: {
|
||||
|
@ -47,11 +47,12 @@ const getResourceFindingsQuery = ({
|
|||
filter: [...(query?.bool?.filter || []), { term: { 'resource_id.keyword': resourceId } }],
|
||||
},
|
||||
},
|
||||
pit: { id: pitId },
|
||||
},
|
||||
ignore_unavailable: false,
|
||||
});
|
||||
|
||||
export const useResourceFindings = ({
|
||||
index,
|
||||
query,
|
||||
resourceId,
|
||||
from,
|
||||
|
@ -62,21 +63,35 @@ export const useResourceFindings = ({
|
|||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
return useQuery(
|
||||
['csp_resource_findings', { index, query, resourceId, from, size }],
|
||||
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
|
||||
const pitId = pitIdRef.current;
|
||||
|
||||
return useQuery<
|
||||
IEsSearchResponse<CspFinding>,
|
||||
unknown,
|
||||
CspFindingsQueryData & { newPitId: string }
|
||||
>(
|
||||
['csp_resource_findings', { query, resourceId, from, size, pitId }],
|
||||
() =>
|
||||
lastValueFrom<IEsSearchResponse<CspFinding>>(
|
||||
data.search.search({
|
||||
params: getResourceFindingsQuery({ index, query, resourceId, from, size }),
|
||||
params: getResourceFindingsQuery({ query, resourceId, from, size, pitId }),
|
||||
})
|
||||
),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
select: ({ rawResponse: { hits } }) => ({
|
||||
select: ({ rawResponse: { hits, pit_id: newPitId } }) => ({
|
||||
page: hits.hits.map((hit) => hit._source!),
|
||||
total: hits.total as number,
|
||||
newPitId: newPitId!,
|
||||
}),
|
||||
onError: (err) => showErrorToast(toasts, err),
|
||||
onSuccess: ({ newPitId }) => {
|
||||
setPitId(newPitId);
|
||||
},
|
||||
// Refetching on an interval to ensure the PIT window stays open
|
||||
refetchInterval: FINDINGS_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,11 +4,14 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { useContext } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { Pagination } from '@elastic/eui';
|
||||
import { FindingsEsPitContext } from '../es_pit/findings_es_pit_context';
|
||||
import { FINDINGS_REFETCH_INTERVAL_MS } from '../constants';
|
||||
import { useKibana } from '../../../common/hooks/use_kibana';
|
||||
import { showErrorToast } from '../latest_findings/use_latest_findings';
|
||||
import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types';
|
||||
|
@ -31,8 +34,26 @@ type FindingsAggResponse = IKibanaSearchResponse<
|
|||
estypes.SearchResponse<{}, FindingsByResourceAggs>
|
||||
>;
|
||||
|
||||
interface FindingsByResourcePage {
|
||||
failed_findings: {
|
||||
count: number;
|
||||
normalized: number;
|
||||
total_findings: number;
|
||||
};
|
||||
resource_id: string;
|
||||
resource_name: string;
|
||||
resource_subtype: string;
|
||||
cluster_id: string;
|
||||
cis_sections: string[];
|
||||
}
|
||||
|
||||
interface UseFindingsByResourceData {
|
||||
page: FindingsByResourcePage[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type CspFindingsByResourceResult = FindingsQueryResult<
|
||||
ReturnType<typeof useFindingsByResource>['data'],
|
||||
UseFindingsByResourceData | undefined,
|
||||
unknown
|
||||
>;
|
||||
|
||||
|
@ -50,12 +71,11 @@ interface FindingsAggBucket extends estypes.AggregationsStringRareTermsBucketKey
|
|||
}
|
||||
|
||||
export const getFindingsByResourceAggQuery = ({
|
||||
index,
|
||||
query,
|
||||
from,
|
||||
size,
|
||||
}: UseResourceFindingsOptions): estypes.SearchRequest => ({
|
||||
index,
|
||||
pitId,
|
||||
}: UseResourceFindingsOptions & { pitId: string }): estypes.SearchRequest => ({
|
||||
body: {
|
||||
query,
|
||||
size: 0,
|
||||
|
@ -95,23 +115,28 @@ export const getFindingsByResourceAggQuery = ({
|
|||
},
|
||||
},
|
||||
},
|
||||
pit: { id: pitId },
|
||||
},
|
||||
ignore_unavailable: false,
|
||||
});
|
||||
|
||||
export const useFindingsByResource = ({ index, query, from, size }: UseResourceFindingsOptions) => {
|
||||
export const useFindingsByResource = ({ query, from, size }: UseResourceFindingsOptions) => {
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
return useQuery(
|
||||
['csp_findings_resource', { index, query, size, from }],
|
||||
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
|
||||
const pitId = pitIdRef.current;
|
||||
|
||||
return useQuery<UseFindingsByResourceData & { newPitId: string }>(
|
||||
['csp_findings_resource', { query, size, from, pitId }],
|
||||
() =>
|
||||
lastValueFrom(
|
||||
data.search.search<FindingsAggRequest, FindingsAggResponse>({
|
||||
params: getFindingsByResourceAggQuery({ index, query, from, size }),
|
||||
params: getFindingsByResourceAggQuery({ query, from, size, pitId }),
|
||||
})
|
||||
).then(({ rawResponse: { aggregations } }) => {
|
||||
).then(({ rawResponse: { aggregations, pit_id: newPitId } }) => {
|
||||
if (!aggregations) throw new Error('expected aggregations to be defined');
|
||||
|
||||
if (!Array.isArray(aggregations.resources.buckets))
|
||||
|
@ -120,16 +145,23 @@ export const useFindingsByResource = ({ index, query, from, size }: UseResourceF
|
|||
return {
|
||||
page: aggregations.resources.buckets.map(createFindingsByResource),
|
||||
total: aggregations.resource_total.value,
|
||||
newPitId: newPitId!,
|
||||
};
|
||||
}),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
onError: (err) => showErrorToast(toasts, err),
|
||||
onSuccess: ({ newPitId }) => {
|
||||
setPitId(newPitId);
|
||||
},
|
||||
// Refetching on an interval to ensure the PIT window stays open
|
||||
refetchInterval: FINDINGS_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createFindingsByResource = (resource: FindingsAggBucket) => {
|
||||
const createFindingsByResource = (resource: FindingsAggBucket): FindingsByResourcePage => {
|
||||
if (
|
||||
!Array.isArray(resource.cis_sections.buckets) ||
|
||||
!Array.isArray(resource.name.buckets) ||
|
||||
|
|
|
@ -4,8 +4,9 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { BoolQuery, Filter, Query } from '@kbn/es-query';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
|
||||
export type FindingsGroupByKind = 'default' | 'resource';
|
||||
|
||||
|
@ -14,8 +15,11 @@ export interface FindingsBaseURLQuery {
|
|||
filters: Filter[];
|
||||
}
|
||||
|
||||
export interface FindingsBaseProps {
|
||||
dataView: DataView;
|
||||
}
|
||||
|
||||
export interface FindingsBaseEsQuery {
|
||||
index: string;
|
||||
query?: {
|
||||
bool: BoolQuery;
|
||||
};
|
||||
|
@ -98,3 +102,8 @@ interface CspFindingAgent {
|
|||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface CspFindingsQueryData {
|
||||
page: CspFinding[];
|
||||
total: number;
|
||||
}
|
||||
|
|
|
@ -8,9 +8,11 @@ import { useQuery } from 'react-query';
|
|||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/public';
|
||||
import { useContext } from 'react';
|
||||
import { useKibana } from '../../common/hooks/use_kibana';
|
||||
import { showErrorToast } from './latest_findings/use_latest_findings';
|
||||
import type { FindingsBaseEsQuery } from './types';
|
||||
import { FindingsEsPitContext } from './es_pit/findings_es_pit_context';
|
||||
|
||||
type FindingsAggRequest = IKibanaSearchRequest<estypes.SearchRequest>;
|
||||
type FindingsAggResponse = IKibanaSearchResponse<estypes.SearchResponse<{}, FindingsAggs>>;
|
||||
|
@ -23,40 +25,57 @@ interface FindingsAggs extends estypes.AggregationsMultiBucketAggregateBase {
|
|||
};
|
||||
}
|
||||
|
||||
export const getFindingsCountAggQuery = ({ index, query }: FindingsBaseEsQuery) => ({
|
||||
index,
|
||||
interface UseFindingsCounterData {
|
||||
passed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export const getFindingsCountAggQuery = ({
|
||||
query,
|
||||
pitId,
|
||||
}: FindingsBaseEsQuery & { pitId: string }) => ({
|
||||
size: 0,
|
||||
track_total_hits: true,
|
||||
body: {
|
||||
query,
|
||||
aggs: { count: { terms: { field: 'result.evaluation.keyword' } } },
|
||||
pit: { id: pitId },
|
||||
},
|
||||
ignore_unavailable: false,
|
||||
});
|
||||
|
||||
export const useFindingsCounter = ({ index, query }: FindingsBaseEsQuery) => {
|
||||
export const useFindingsCounter = ({ query }: FindingsBaseEsQuery) => {
|
||||
const {
|
||||
data,
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
return useQuery(
|
||||
['csp_findings_counts', { index, query }],
|
||||
const { pitIdRef, setPitId } = useContext(FindingsEsPitContext);
|
||||
const pitId = pitIdRef.current;
|
||||
|
||||
return useQuery<FindingsAggResponse, unknown, UseFindingsCounterData & { newPitId: string }>(
|
||||
['csp_findings_counts', { query, pitId }],
|
||||
() =>
|
||||
lastValueFrom(
|
||||
data.search.search<FindingsAggRequest, FindingsAggResponse>({
|
||||
params: getFindingsCountAggQuery({ index, query }),
|
||||
params: getFindingsCountAggQuery({ query, pitId }),
|
||||
})
|
||||
),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
onError: (err) => showErrorToast(toasts, err),
|
||||
select: (response) =>
|
||||
Object.fromEntries(
|
||||
select: (response) => ({
|
||||
...(Object.fromEntries(
|
||||
response.rawResponse.aggregations!.count.buckets.map((bucket) => [
|
||||
bucket.key,
|
||||
bucket.doc_count,
|
||||
])!
|
||||
) as Record<'passed' | 'failed', number>,
|
||||
) as { passed: number; failed: number }),
|
||||
newPitId: response.rawResponse.pit_id!,
|
||||
}),
|
||||
onSuccess: ({ newPitId }) => {
|
||||
setPitId(newPitId);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,16 +6,15 @@
|
|||
*/
|
||||
|
||||
import { buildEsQuery } from '@kbn/es-query';
|
||||
import type { DataView } from '@kbn/data-plugin/common';
|
||||
import { EuiBasicTableProps, Pagination } from '@elastic/eui';
|
||||
import { FindingsBaseProps } from './types';
|
||||
import type { FindingsBaseEsQuery, FindingsBaseURLQuery } from './types';
|
||||
|
||||
export const getBaseQuery = ({
|
||||
dataView,
|
||||
query,
|
||||
filters,
|
||||
}: FindingsBaseURLQuery & { dataView: DataView }): FindingsBaseEsQuery => ({
|
||||
index: dataView.title,
|
||||
}: FindingsBaseURLQuery & FindingsBaseProps): FindingsBaseEsQuery => ({
|
||||
// TODO: this will throw for malformed query
|
||||
// page will display an error boundary with the JS error
|
||||
// will be accounted for before releasing the feature
|
||||
|
|
|
@ -28,20 +28,17 @@ export type RulesQueryResult = ReturnType<typeof useFindCspRules>;
|
|||
export const useFindCspRules = ({ search, page, perPage, filter }: RulesQuery) => {
|
||||
const { savedObjects } = useKibana().services;
|
||||
|
||||
return useQuery(
|
||||
[cspRuleAssetSavedObjectType, { search, page, perPage }],
|
||||
() =>
|
||||
savedObjects.client.find<CspRuleSchema>({
|
||||
type: cspRuleAssetSavedObjectType,
|
||||
search,
|
||||
searchFields: ['name'],
|
||||
page: 1,
|
||||
// NOTE: 'name.raw' is a field mapping we defined on 'name'
|
||||
sortField: 'name.raw',
|
||||
perPage,
|
||||
filter,
|
||||
}),
|
||||
{ refetchOnWindowFocus: false }
|
||||
return useQuery([cspRuleAssetSavedObjectType, { search, page, perPage }], () =>
|
||||
savedObjects.client.find<CspRuleSchema>({
|
||||
type: cspRuleAssetSavedObjectType,
|
||||
search,
|
||||
searchFields: ['name'],
|
||||
page: 1,
|
||||
// NOTE: 'name.raw' is a field mapping we defined on 'name'
|
||||
sortField: 'name.raw',
|
||||
perPage,
|
||||
filter,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -33,5 +33,16 @@ export const createReactQueryResponse = <TData = unknown, TError = unknown>({
|
|||
return { status, data: undefined, isSuccess: false, isLoading: true, isError: false };
|
||||
}
|
||||
|
||||
if (status === 'idle') {
|
||||
return {
|
||||
status,
|
||||
data: undefined,
|
||||
isSuccess: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { status };
|
||||
};
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* 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 { Chance } from 'chance';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { elasticsearchClientMock } from '@kbn/core/server/elasticsearch/client/mocks';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { httpServerMock, httpServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { DEFAULT_PIT_KEEP_ALIVE, defineEsPitRoute, esPitInputSchema } from './es_pit';
|
||||
import { CspAppService } from '../../lib/csp_app_services';
|
||||
import { CspAppContext } from '../../plugin';
|
||||
|
||||
describe('ES Point in time API endpoint', () => {
|
||||
const chance = new Chance();
|
||||
let mockEsClient: jest.Mocked<ElasticsearchClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('validate the API route path', () => {
|
||||
const router = httpServiceMock.createRouter();
|
||||
const cspContext: CspAppContext = {
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
service: new CspAppService(),
|
||||
};
|
||||
|
||||
defineEsPitRoute(router, cspContext);
|
||||
|
||||
const [config] = router.post.mock.calls[0];
|
||||
expect(config.path).toEqual('/internal/cloud_security_posture/es_pit');
|
||||
});
|
||||
|
||||
it('should accept to a user with fleet.all privilege', async () => {
|
||||
const router = httpServiceMock.createRouter();
|
||||
const cspContext: CspAppContext = {
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
service: new CspAppService(),
|
||||
};
|
||||
|
||||
defineEsPitRoute(router, cspContext);
|
||||
|
||||
const mockContext = {
|
||||
fleet: { authz: { fleet: { all: true } } },
|
||||
};
|
||||
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest();
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
|
||||
const [_, handler] = router.post.mock.calls[0];
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(res.forbidden).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should reject to a user without fleet.all privilege', async () => {
|
||||
const router = httpServiceMock.createRouter();
|
||||
const cspContext: CspAppContext = {
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
service: new CspAppService(),
|
||||
};
|
||||
|
||||
defineEsPitRoute(router, cspContext);
|
||||
|
||||
const mockContext = {
|
||||
fleet: { authz: { fleet: { all: false } } },
|
||||
};
|
||||
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest();
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
|
||||
const [_, handler] = router.post.mock.calls[0];
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(res.forbidden).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return the newly created PIT ID from ES', async () => {
|
||||
const router = httpServiceMock.createRouter();
|
||||
const cspContext: CspAppContext = {
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
service: new CspAppService(),
|
||||
};
|
||||
|
||||
defineEsPitRoute(router, cspContext);
|
||||
|
||||
const pitId = chance.string();
|
||||
mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
mockEsClient.openPointInTime.mockImplementation(() => Promise.resolve({ id: pitId }));
|
||||
|
||||
const mockContext = {
|
||||
fleet: { authz: { fleet: { all: true } } },
|
||||
core: { elasticsearch: { client: { asCurrentUser: mockEsClient } } },
|
||||
};
|
||||
|
||||
const indexName = chance.string();
|
||||
const keepAlive = chance.string();
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
query: { index_name: indexName, keep_alive: keepAlive },
|
||||
});
|
||||
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
const [_, handler] = router.post.mock.calls[0];
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(mockEsClient.openPointInTime).toHaveBeenCalledTimes(1);
|
||||
expect(mockEsClient.openPointInTime).toHaveBeenLastCalledWith({
|
||||
index: indexName,
|
||||
keep_alive: keepAlive,
|
||||
});
|
||||
|
||||
expect(res.ok).toHaveBeenCalledTimes(1);
|
||||
expect(res.ok).toHaveBeenLastCalledWith({ body: pitId });
|
||||
});
|
||||
|
||||
describe('test input schema', () => {
|
||||
it('passes keep alive and index name parameters', () => {
|
||||
const indexName = chance.string();
|
||||
const keepAlive = chance.string();
|
||||
const validatedQuery = esPitInputSchema.validate({
|
||||
index_name: indexName,
|
||||
keep_alive: keepAlive,
|
||||
});
|
||||
|
||||
expect(validatedQuery).toMatchObject({
|
||||
index_name: indexName,
|
||||
keep_alive: keepAlive,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates default keep alive parameter value', () => {
|
||||
const indexName = chance.string();
|
||||
const validatedQuery = esPitInputSchema.validate({ index_name: indexName });
|
||||
|
||||
expect(validatedQuery).toMatchObject({
|
||||
index_name: indexName,
|
||||
keep_alive: DEFAULT_PIT_KEEP_ALIVE,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when index name parameter is not passed', () => {
|
||||
expect(() => {
|
||||
esPitInputSchema.validate({});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('throws when index name parameter is not a string', () => {
|
||||
const indexName = chance.integer();
|
||||
expect(() => {
|
||||
esPitInputSchema.validate({ index_name: indexName });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('throws when keep alive parameter is not a string', () => {
|
||||
const indexName = chance.string();
|
||||
const keepAlive = chance.integer();
|
||||
expect(() => {
|
||||
esPitInputSchema.validate({ index_name: indexName, keep_alive: keepAlive });
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import { ES_PIT_ROUTE_PATH } from '../../../common/constants';
|
||||
import type { CspAppContext } from '../../plugin';
|
||||
import type { CspRouter } from '../../types';
|
||||
|
||||
export const DEFAULT_PIT_KEEP_ALIVE = '1m';
|
||||
|
||||
export const esPitInputSchema = schema.object({
|
||||
index_name: schema.string(),
|
||||
keep_alive: schema.string({ defaultValue: DEFAULT_PIT_KEEP_ALIVE }),
|
||||
});
|
||||
|
||||
export const defineEsPitRoute = (router: CspRouter, cspContext: CspAppContext): void =>
|
||||
router.post(
|
||||
{
|
||||
path: ES_PIT_ROUTE_PATH,
|
||||
validate: { query: esPitInputSchema },
|
||||
},
|
||||
async (context, request, response) => {
|
||||
if (!(await context.fleet).authz.fleet.all) {
|
||||
return response.forbidden();
|
||||
}
|
||||
|
||||
try {
|
||||
const coreContext = await context.core;
|
||||
const esClient = coreContext.elasticsearch.client.asCurrentUser;
|
||||
const { id } = await esClient.openPointInTime({
|
||||
index: request.query.index_name,
|
||||
keep_alive: request.query.keep_alive,
|
||||
});
|
||||
|
||||
return response.ok({ body: id });
|
||||
} catch (err) {
|
||||
const error = transformError(err);
|
||||
cspContext.logger.error(`Failed to open Elasticsearch point in time: ${error}`);
|
||||
return response.customError({
|
||||
body: { message: error.message },
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
|
@ -9,6 +9,7 @@ import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compli
|
|||
import { defineGetBenchmarksRoute } from './benchmarks/benchmarks';
|
||||
import { defineUpdateRulesConfigRoute } from './configuration/update_rules_configuration';
|
||||
import { defineGetCspSetupStatusRoute } from './setup_status/setup_status';
|
||||
import { defineEsPitRoute } from './es_pit/es_pit';
|
||||
import { CspAppContext } from '../plugin';
|
||||
import { CspRouter } from '../types';
|
||||
|
||||
|
@ -17,4 +18,5 @@ export function defineRoutes(router: CspRouter, cspContext: CspAppContext) {
|
|||
defineGetBenchmarksRoute(router, cspContext);
|
||||
defineUpdateRulesConfigRoute(router, cspContext);
|
||||
defineGetCspSetupStatusRoute(router, cspContext);
|
||||
defineEsPitRoute(router, cspContext);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue