[Security Solution][Detections] Refactor ML calls for newest ML permissions (#74582)

## Summary

Addresses https://github.com/elastic/kibana/issues/73567.

ML Users (role: `machine_learning_user`) were previously able to invoke the ML Recognizer API, which we use to get not-yet-installed ML Jobs relevant to our index patterns. As of https://github.com/elastic/kibana/pull/64662 this is not true, and so we receive errors from components using the underlying hook, `useSiemJobs`.

To solve this I've created two separate hooks to replace `useSiemJobs`:

* `useSecurityJobs`
  * used on ML Popover
  * includes uninstalled ML Jobs
  * checks (and returns) `isMlAdmin` before fetching data
* `useInstalledSecurityJobs`
  * used on ML Jobs Dropdown and Anomalies Table
  * includes only installed ML Jobs
  * checks (and returns) `isMlUser` before fetching data

Note that we while we now receive the knowledge to do so, we do not always inform the user in the case of invalid permissions, and instead have the following behaviors:

#### User has insufficient license
* ML Popover:  shows an upgrade CTA
* Anomalies Tables: show no data
* Rule Creation: ML Rule option is disabled, shows upgrade CTA
* Rule Details: ML Job Id is displayed as text
#### User is ML User
* ML Popover:  not shown
* Anomalies Tables: show no data
* Rule Creation: ML Rule option is disabled
* Rule Details: ML Job Id is displayed as text
#### User is ML Admin
* ML Popover:  shown
* Anomalies Tables: show data __for installed ML Jobs__
  * This is the same as previous logic, but worth calling out that you can't view historical anomalies
* Rule Creation: ML Rule option is enabled, all ML Jobs available
* Rule Details: ML Job Id is displayed as hyperlink, job status badge shown

### Checklist

- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
This commit is contained in:
Ryland Herrick 2020-08-12 19:26:06 -05:00 committed by GitHub
parent a735a9f825
commit 4ca52e678a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 733 additions and 407 deletions

View file

@ -36,8 +36,8 @@ export interface MlSummaryJob {
export interface AuditMessage {
job_id: string;
msgTime: number;
level: number;
highestLevel: number;
level: string;
highestLevel: string;
highestLevelText: string;
text: string;
}

View file

@ -9,6 +9,7 @@ export * from '../common/constants/anomalies';
export * from '../common/types/data_recognizer';
export * from '../common/types/capabilities';
export * from '../common/types/anomalies';
export * from '../common/types/anomaly_detection_jobs';
export * from '../common/types/modules';
export * from '../common/types/audit_message';

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { emptyMlCapabilities } from './empty_ml_capabilities';
import { hasMlLicense } from './has_ml_license';
describe('hasMlLicense', () => {
test('it returns false when license is not platinum or trial', () => {
const capabilities = { ...emptyMlCapabilities, isPlatinumOrTrialLicense: false };
expect(hasMlLicense(capabilities)).toEqual(false);
});
test('it returns true when license is platinum or trial', () => {
const capabilities = { ...emptyMlCapabilities, isPlatinumOrTrialLicense: true };
expect(hasMlLicense(capabilities)).toEqual(true);
});
});

View file

@ -0,0 +1,10 @@
/*
* 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 { MlCapabilitiesResponse } from '../../../ml/common/types/capabilities';
export const hasMlLicense = (capabilities: MlCapabilitiesResponse): boolean =>
capabilities.isPlatinumOrTrialLicense;

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { MlSummaryJob } from '../../../ml/common/types/anomaly_detection_jobs';
import { ML_GROUP_IDS } from '../constants';
export const isSecurityJob = (job: MlSummaryJob): boolean =>
export const isSecurityJob = (job: { groups: string[] }): boolean =>
job.groups.some((group) => ML_GROUP_IDS.includes(group));

View file

@ -9,13 +9,11 @@ import { useState, useEffect, useMemo } from 'react';
import { DEFAULT_ANOMALY_SCORE } from '../../../../../common/constants';
import { anomaliesTableData } from '../api/anomalies_table_data';
import { InfluencerInput, Anomalies, CriteriaFields } from '../types';
import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
import { useSiemJobs } from '../../ml_popover/hooks/use_siem_jobs';
import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities';
import { useStateToaster, errorToToaster } from '../../toasters';
import * as i18n from './translations';
import { useTimeZone, useUiSetting$ } from '../../../lib/kibana';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useInstalledSecurityJobs } from '../hooks/use_installed_security_jobs';
interface Args {
influencers?: InfluencerInput[];
@ -58,15 +56,13 @@ export const useAnomaliesTableData = ({
skip = false,
}: Args): Return => {
const [tableData, setTableData] = useState<Anomalies | null>(null);
const [, siemJobs] = useSiemJobs(true);
const { isMlUser, jobs } = useInstalledSecurityJobs();
const [loading, setLoading] = useState(true);
const capabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(capabilities);
const [, dispatchToaster] = useStateToaster();
const { addError } = useAppToasts();
const timeZone = useTimeZone();
const [anomalyScore] = useUiSetting$<number>(DEFAULT_ANOMALY_SCORE);
const siemJobIds = siemJobs.filter((job) => job.isInstalled).map((job) => job.id);
const jobIds = jobs.map((job) => job.id);
const startDateMs = useMemo(() => new Date(startDate).getTime(), [startDate]);
const endDateMs = useMemo(() => new Date(endDate).getTime(), [endDate]);
@ -81,11 +77,11 @@ export const useAnomaliesTableData = ({
earliestMs: number,
latestMs: number
) {
if (userPermissions && !skip && siemJobIds.length > 0) {
if (isMlUser && !skip && jobIds.length > 0) {
try {
const data = await anomaliesTableData(
{
jobIds: siemJobIds,
jobIds,
criteriaFields: criteriaFieldsInput,
aggregationInterval: 'auto',
threshold: getThreshold(anomalyScore, threshold),
@ -104,13 +100,13 @@ export const useAnomaliesTableData = ({
}
} catch (error) {
if (isSubscribed) {
errorToToaster({ title: i18n.SIEM_TABLE_FETCH_FAILURE, error, dispatchToaster });
addError(error, { title: i18n.SIEM_TABLE_FETCH_FAILURE });
setLoading(false);
}
}
} else if (!userPermissions && isSubscribed) {
} else if (!isMlUser && isSubscribed) {
setLoading(false);
} else if (siemJobIds.length === 0 && isSubscribed) {
} else if (jobIds.length === 0 && isSubscribed) {
setLoading(false);
} else if (isSubscribed) {
setTableData(null);
@ -132,9 +128,9 @@ export const useAnomaliesTableData = ({
startDateMs,
endDateMs,
skip,
userPermissions,
isMlUser,
// eslint-disable-next-line react-hooks/exhaustive-deps
siemJobIds.sort().join(),
jobIds.sort().join(),
]);
return [loading, tableData];

View file

@ -0,0 +1,35 @@
/*
* 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 { HttpSetup } from '../../../../../../../../src/core/public';
import { MlSummaryJob } from '../../../../../../ml/public';
export interface GetJobsSummaryArgs {
http: HttpSetup;
jobIds?: string[];
signal: AbortSignal;
}
/**
* Fetches a summary of all ML jobs currently installed
*
* @param http HTTP Service
* @param jobIds Array of job IDs to filter against
* @param signal to cancel request
*
* @throws An error if response is not OK
*/
export const getJobsSummary = async ({
http,
jobIds,
signal,
}: GetJobsSummaryArgs): Promise<MlSummaryJob[]> =>
http.fetch<MlSummaryJob[]>('/api/ml/jobs/jobs_summary', {
method: 'POST',
body: JSON.stringify({ jobIds: jobIds ?? [] }),
asSystemRequest: true,
signal,
});

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from '../../../../../../../../src/core/public';
import { MlCapabilitiesResponse } from '../../../../../../ml/public';
import { KibanaServices } from '../../../lib/kibana';
import { InfluencerInput } from '../types';
export interface Body {
@ -21,10 +21,15 @@ export interface Body {
maxExamples: number;
}
export const getMlCapabilities = async (signal: AbortSignal): Promise<MlCapabilitiesResponse> => {
return KibanaServices.get().http.fetch<MlCapabilitiesResponse>('/api/ml/ml_capabilities', {
export const getMlCapabilities = async ({
http,
signal,
}: {
http: HttpSetup;
signal: AbortSignal;
}): Promise<MlCapabilitiesResponse> =>
http.fetch<MlCapabilitiesResponse>('/api/ml/ml_capabilities', {
method: 'GET',
asSystemRequest: true,
signal,
});
};

View file

@ -0,0 +1,12 @@
/*
* 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 { useAsync, withOptionalSignal } from '../../../../shared_imports';
import { getJobsSummary } from '../api/get_jobs_summary';
const _getJobsSummary = withOptionalSignal(getJobsSummary);
export const useGetJobsSummary = () => useAsync(_getJobsSummary);

View file

@ -0,0 +1,12 @@
/*
* 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 { getMlCapabilities } from '../api/get_ml_capabilities';
import { useAsync, withOptionalSignal } from '../../../../shared_imports';
const _getMlCapabilities = withOptionalSignal(getMlCapabilities);
export const useGetMlCapabilities = () => useAsync(_getMlCapabilities);

View file

@ -0,0 +1,99 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license';
import { isSecurityJob } from '../../../../../common/machine_learning/is_security_job';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useAppToastsMock } from '../../../hooks/use_app_toasts.mock';
import { mockJobsSummaryResponse } from '../../ml_popover/api.mock';
import { getJobsSummary } from '../api/get_jobs_summary';
import { useInstalledSecurityJobs } from './use_installed_security_jobs';
jest.mock('../../../../../common/machine_learning/has_ml_user_permissions');
jest.mock('../../../../../common/machine_learning/has_ml_license');
jest.mock('../../../hooks/use_app_toasts');
jest.mock('../api/get_jobs_summary');
describe('useInstalledSecurityJobs', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
(getJobsSummary as jest.Mock).mockResolvedValue(mockJobsSummaryResponse);
});
describe('when the user has permissions', () => {
beforeEach(() => {
(hasMlUserPermissions as jest.Mock).mockReturnValue(true);
(hasMlLicense as jest.Mock).mockReturnValue(true);
});
it('returns jobs and permissions', async () => {
const { result, waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs());
await waitForNextUpdate();
expect(result.current.jobs).toHaveLength(3);
expect(result.current.jobs).toEqual(
expect.arrayContaining([
{
datafeedId: 'datafeed-siem-api-rare_process_linux_ecs',
datafeedIndices: ['auditbeat-*'],
datafeedState: 'stopped',
description: 'SIEM Auditbeat: Detect unusually rare processes on Linux (beta)',
earliestTimestampMs: 1557353420495,
groups: ['siem'],
hasDatafeed: true,
id: 'siem-api-rare_process_linux_ecs',
isSingleMetricViewerJob: true,
jobState: 'closed',
latestTimestampMs: 1557434782207,
memory_status: 'hard_limit',
processed_record_count: 582251,
},
])
);
expect(result.current.isMlUser).toEqual(true);
expect(result.current.isLicensed).toEqual(true);
});
it('filters out non-security jobs', async () => {
const { result, waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs());
await waitForNextUpdate();
expect(result.current.jobs.length).toBeGreaterThan(0);
expect(result.current.jobs.every(isSecurityJob)).toEqual(true);
});
it('renders a toast error if the ML call fails', async () => {
(getJobsSummary as jest.Mock).mockRejectedValue('whoops');
const { waitForNextUpdate } = renderHook(() => useInstalledSecurityJobs());
await waitForNextUpdate();
expect(appToastsMock.addError).toHaveBeenCalledWith('whoops', {
title: 'Security job fetch failure',
});
});
});
describe('when the user does not have valid permissions', () => {
beforeEach(() => {
(hasMlUserPermissions as jest.Mock).mockReturnValue(false);
(hasMlLicense as jest.Mock).mockReturnValue(false);
});
it('returns empty jobs and false predicates', () => {
const { result } = renderHook(() => useInstalledSecurityJobs());
expect(result.current.jobs).toEqual([]);
expect(result.current.isMlUser).toEqual(false);
expect(result.current.isLicensed).toEqual(false);
});
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 { useEffect, useState } from 'react';
import { MlSummaryJob } from '../../../../../../ml/public';
import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license';
import { isSecurityJob } from '../../../../../common/machine_learning/is_security_job';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useHttp } from '../../../lib/kibana';
import { useMlCapabilities } from './use_ml_capabilities';
import * as i18n from '../translations';
import { useGetJobsSummary } from './use_get_jobs_summary';
export interface UseInstalledSecurityJobsReturn {
loading: boolean;
jobs: MlSummaryJob[];
isMlUser: boolean;
isLicensed: boolean;
}
/**
* Returns a collection of installed ML jobs (MlSummaryJob) relevant to
* Security Solution, i.e. all installed jobs in the `security` ML group.
* Use the corresponding helper functions to filter the job list as
* necessary (running jobs, etc).
*
*/
export const useInstalledSecurityJobs = (): UseInstalledSecurityJobsReturn => {
const [jobs, setJobs] = useState<MlSummaryJob[]>([]);
const { addError } = useAppToasts();
const mlCapabilities = useMlCapabilities();
const http = useHttp();
const { error, loading, result, start } = useGetJobsSummary();
const isMlUser = hasMlUserPermissions(mlCapabilities);
const isLicensed = hasMlLicense(mlCapabilities);
useEffect(() => {
if (isMlUser && isLicensed) {
start({ http });
}
}, [http, isMlUser, isLicensed, start]);
useEffect(() => {
if (result) {
const securityJobs = result.filter(isSecurityJob);
setJobs(securityJobs);
}
}, [result]);
useEffect(() => {
if (error) {
addError(error, { title: i18n.SIEM_JOB_FETCH_FAILURE });
}
}, [addError, error]);
return { isLicensed, isMlUser, jobs, loading };
};

View file

@ -6,6 +6,6 @@
import { useContext } from 'react';
import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider';
import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider';
export const useMlCapabilities = () => useContext(MlCapabilitiesContext);

View file

@ -8,9 +8,9 @@ import React, { useState, useEffect } from 'react';
import { MlCapabilitiesResponse } from '../../../../../../ml/public';
import { emptyMlCapabilities } from '../../../../../common/machine_learning/empty_ml_capabilities';
import { getMlCapabilities } from '../api/get_ml_capabilities';
import { errorToToaster, useStateToaster } from '../../toasters';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useHttp } from '../../../lib/kibana';
import { useGetMlCapabilities } from '../hooks/use_get_ml_capabilities';
import * as i18n from './translations';
interface MlCapabilitiesProvider extends MlCapabilitiesResponse {
@ -32,36 +32,27 @@ export const MlCapabilitiesProvider = React.memo<{ children: JSX.Element }>(({ c
const [capabilities, setCapabilities] = useState<MlCapabilitiesProvider>(
emptyMlCapabilitiesProvider
);
const [, dispatchToaster] = useStateToaster();
const http = useHttp();
const { addError } = useAppToasts();
const { start, result, error } = useGetMlCapabilities();
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
start({ http });
}, [http, start]);
async function fetchMlCapabilities() {
try {
const mlCapabilities = await getMlCapabilities(abortCtrl.signal);
if (isSubscribed) {
setCapabilities({ ...mlCapabilities, capabilitiesFetched: true });
}
} catch (error) {
if (isSubscribed) {
errorToToaster({
title: i18n.MACHINE_LEARNING_PERMISSIONS_FAILURE,
error,
dispatchToaster,
});
}
}
useEffect(() => {
if (result) {
setCapabilities({ ...result, capabilitiesFetched: true });
}
}, [result]);
fetchMlCapabilities();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (error) {
addError(error, {
title: i18n.MACHINE_LEARNING_PERMISSIONS_FAILURE,
});
}
}, [addError, error]);
return (
<MlCapabilitiesContext.Provider value={capabilities}>{children}</MlCapabilitiesContext.Provider>

View file

@ -16,7 +16,7 @@ import { convertAnomaliesToHosts } from './convert_anomalies_to_hosts';
import { Loader } from '../../loader';
import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies';
import { AnomaliesHostTableProps } from '../types';
import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../hooks/use_ml_capabilities';
import { BasicTable } from './basic_table';
import { hostEquality } from './host_equality';
import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type';

View file

@ -14,7 +14,7 @@ import { convertAnomaliesToNetwork } from './convert_anomalies_to_network';
import { Loader } from '../../loader';
import { AnomaliesNetworkTableProps } from '../types';
import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns';
import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../hooks/use_ml_capabilities';
import { BasicTable } from './basic_table';
import { networkEquality } from './network_equality';
import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type';

View file

@ -4,16 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { MlSummaryJob } from '../../../../../ml/public';
import {
Group,
JobSummary,
Module,
RecognizerModule,
SetupMlResponse,
SiemJob,
SecurityJob,
StartDatafeedResponse,
StopDatafeedResponse,
} from '../types';
} from './types';
export const mockGroupsResponse: Group[] = [
{
@ -31,7 +31,7 @@ export const mockGroupsResponse: Group[] = [
{ id: 'suricata', jobIds: ['suricata_alert_rate'], calendarIds: [] },
];
export const mockOpenedJob: JobSummary = {
export const mockOpenedJob: MlSummaryJob = {
datafeedId: 'datafeed-siem-api-rare_process_linux_ecs',
datafeedIndices: ['auditbeat-*'],
datafeedState: 'started',
@ -48,7 +48,7 @@ export const mockOpenedJob: JobSummary = {
processed_record_count: 3425264,
};
export const mockJobsSummaryResponse: JobSummary[] = [
export const mockJobsSummaryResponse: MlSummaryJob[] = [
{
id: 'rc-rare-process-windows-5',
description:
@ -491,7 +491,7 @@ export const mockStopDatafeedsSuccess: StopDatafeedResponse = {
'datafeed-linux_anomalous_network_service': { stopped: true },
};
export const mockSiemJobs: SiemJob[] = [
export const mockSecurityJobs: SecurityJob[] = [
{
id: 'linux_anomalous_network_activity_ecs',
description:

View file

@ -9,7 +9,6 @@ import {
CloseJobsResponse,
ErrorResponse,
GetModulesProps,
JobSummary,
MlSetupArgs,
Module,
RecognizerModule,
@ -165,21 +164,3 @@ export const stopDatafeeds = async ({
return [stopDatafeedsResponse, closeJobsResponse];
};
/**
* Fetches a summary of all ML jobs currently installed
*
* NOTE: If not sending jobIds in the body, you must at least send an empty body or the server will
* return a 500
*
* @param signal to cancel request
*
* @throws An error if response is not OK
*/
export const getJobsSummary = async (signal: AbortSignal): Promise<JobSummary[]> =>
KibanaServices.get().http.fetch<JobSummary[]>('/api/ml/jobs/jobs_summary', {
method: 'POST',
body: JSON.stringify({}),
asSystemRequest: true,
signal,
});

View file

@ -4,14 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mockSiemJobs } from './__mocks__/api';
import { mockSecurityJobs } from './api.mock';
import { filterJobs, getStablePatternTitles, searchFilter } from './helpers';
describe('helpers', () => {
describe('filterJobs', () => {
test('returns all jobs when no filter is suplied', () => {
const filteredJobs = filterJobs({
jobs: mockSiemJobs,
jobs: mockSecurityJobs,
selectedGroups: [],
showCustomJobs: false,
showElasticJobs: false,
@ -23,17 +23,17 @@ describe('helpers', () => {
describe('searchFilter', () => {
test('returns all jobs when nullfilterQuery is provided', () => {
const jobsToDisplay = searchFilter(mockSiemJobs);
expect(jobsToDisplay.length).toEqual(mockSiemJobs.length);
const jobsToDisplay = searchFilter(mockSecurityJobs);
expect(jobsToDisplay.length).toEqual(mockSecurityJobs.length);
});
test('returns correct DisplayJobs when filterQuery matches job.id', () => {
const jobsToDisplay = searchFilter(mockSiemJobs, 'rare_process');
const jobsToDisplay = searchFilter(mockSecurityJobs, 'rare_process');
expect(jobsToDisplay.length).toEqual(2);
});
test('returns correct DisplayJobs when filterQuery matches job.description', () => {
const jobsToDisplay = searchFilter(mockSiemJobs, 'Detect unusually');
const jobsToDisplay = searchFilter(mockSecurityJobs, 'Detect unusually');
expect(jobsToDisplay.length).toEqual(2);
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SiemJob } from './types';
import { SecurityJob } from './types';
/**
* Returns a filtered array of Jobs according to JobsTableFilters selections
@ -22,12 +22,12 @@ export const filterJobs = ({
showElasticJobs,
filterQuery,
}: {
jobs: SiemJob[];
jobs: SecurityJob[];
selectedGroups: string[];
showCustomJobs: boolean;
showElasticJobs: boolean;
filterQuery: string;
}): SiemJob[] =>
}): SecurityJob[] =>
searchFilter(
jobs
.filter((job) => !showCustomJobs || (showCustomJobs && !job.isElasticJob))
@ -44,7 +44,7 @@ export const filterJobs = ({
* @param jobs to filter
* @param filterQuery user-provided search string to filter for occurrence in job names/description
*/
export const searchFilter = (jobs: SiemJob[], filterQuery?: string): SiemJob[] =>
export const searchFilter = (jobs: SecurityJob[], filterQuery?: string): SecurityJob[] =>
jobs.filter((job) =>
filterQuery == null
? true

View file

@ -0,0 +1,110 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions';
import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useAppToastsMock } from '../../../hooks/use_app_toasts.mock';
import { getJobsSummary } from '../../ml/api/get_jobs_summary';
import { checkRecognizer, getModules } from '../api';
import { SecurityJob } from '../types';
import {
mockJobsSummaryResponse,
mockGetModuleResponse,
checkRecognizerSuccess,
} from '../api.mock';
import { useSecurityJobs } from './use_security_jobs';
jest.mock('../../../../../common/machine_learning/has_ml_admin_permissions');
jest.mock('../../../../../common/machine_learning/has_ml_license');
jest.mock('../../../lib/kibana');
jest.mock('../../../hooks/use_app_toasts');
jest.mock('../../ml/hooks/use_ml_capabilities');
jest.mock('../../ml/api/get_jobs_summary');
jest.mock('../api');
describe('useSecurityJobs', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
});
describe('when user has valid permissions', () => {
beforeEach(() => {
(hasMlAdminPermissions as jest.Mock).mockReturnValue(true);
(hasMlLicense as jest.Mock).mockReturnValue(true);
(getJobsSummary as jest.Mock).mockResolvedValue(mockJobsSummaryResponse);
(getModules as jest.Mock).mockResolvedValue(mockGetModuleResponse);
(checkRecognizer as jest.Mock).mockResolvedValue(checkRecognizerSuccess);
});
it('combines multiple ML calls into an array of SecurityJobs', async () => {
const expectedSecurityJob: SecurityJob = {
datafeedId: 'datafeed-siem-api-rare_process_linux_ecs',
datafeedIndices: ['auditbeat-*'],
datafeedState: 'stopped',
defaultIndexPattern: '',
description: 'SIEM Auditbeat: Detect unusually rare processes on Linux (beta)',
earliestTimestampMs: 1557353420495,
groups: ['siem'],
hasDatafeed: true,
id: 'siem-api-rare_process_linux_ecs',
isCompatible: true,
isElasticJob: false,
isInstalled: true,
isSingleMetricViewerJob: true,
jobState: 'closed',
latestTimestampMs: 1557434782207,
memory_status: 'hard_limit',
moduleId: '',
processed_record_count: 582251,
};
const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(false));
await waitForNextUpdate();
expect(result.current.jobs).toHaveLength(6);
expect(result.current.jobs).toEqual(expect.arrayContaining([expectedSecurityJob]));
});
it('returns those permissions', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSecurityJobs(false));
await waitForNextUpdate();
expect(result.current.isMlAdmin).toEqual(true);
expect(result.current.isLicensed).toEqual(true);
});
it('renders a toast error if an ML call fails', async () => {
(getModules as jest.Mock).mockRejectedValue('whoops');
const { waitForNextUpdate } = renderHook(() => useSecurityJobs(false));
await waitForNextUpdate();
expect(appToastsMock.addError).toHaveBeenCalledWith('whoops', {
title: 'Security job fetch failure',
});
});
});
describe('when the user does not have valid permissions', () => {
beforeEach(() => {
(hasMlAdminPermissions as jest.Mock).mockReturnValue(false);
(hasMlLicense as jest.Mock).mockReturnValue(false);
});
it('returns empty jobs and false predicates', () => {
const { result } = renderHook(() => useSecurityJobs(false));
expect(result.current.jobs).toEqual([]);
expect(result.current.isMlAdmin).toEqual(false);
expect(result.current.isLicensed).toEqual(false);
});
});
});

View file

@ -0,0 +1,95 @@
/*
* 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 { useEffect, useState } from 'react';
import { DEFAULT_INDEX_KEY } from '../../../../../common/constants';
import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions';
import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useUiSetting$, useHttp } from '../../../lib/kibana';
import { checkRecognizer, getModules } from '../api';
import { SecurityJob } from '../types';
import { createSecurityJobs } from './use_security_jobs_helpers';
import { useMlCapabilities } from '../../ml/hooks/use_ml_capabilities';
import * as i18n from '../../ml/translations';
import { getJobsSummary } from '../../ml/api/get_jobs_summary';
export interface UseSecurityJobsReturn {
loading: boolean;
jobs: SecurityJob[];
isMlAdmin: boolean;
isLicensed: boolean;
}
/**
* Compiles a collection of SecurityJobs, which are a list of all jobs relevant to the Security Solution App. This
* includes all installed jobs in the `Security` ML group, and all jobs within ML Modules defined in
* ml_module (whether installed or not). Use the corresponding helper functions to filter the job
* list as necessary. E.g. installed jobs, running jobs, etc.
*
* NOTE: If the user is not an ml admin, jobs will be empty and isMlAdmin will be false.
*
* @param refetchData
*/
export const useSecurityJobs = (refetchData: boolean): UseSecurityJobsReturn => {
const [jobs, setJobs] = useState<SecurityJob[]>([]);
const [loading, setLoading] = useState(true);
const mlCapabilities = useMlCapabilities();
const [siemDefaultIndex] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY);
const http = useHttp();
const { addError } = useAppToasts();
const isMlAdmin = hasMlAdminPermissions(mlCapabilities);
const isLicensed = hasMlLicense(mlCapabilities);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
setLoading(true);
async function fetchSecurityJobIdsFromGroupsData() {
if (isMlAdmin && isLicensed) {
try {
// Batch fetch all installed jobs, ML modules, and check which modules are compatible with siemDefaultIndex
const [jobSummaryData, modulesData, compatibleModules] = await Promise.all([
getJobsSummary({ http, signal: abortCtrl.signal }),
getModules({ signal: abortCtrl.signal }),
checkRecognizer({
indexPatternName: siemDefaultIndex,
signal: abortCtrl.signal,
}),
]);
const compositeSecurityJobs = createSecurityJobs(
jobSummaryData,
modulesData,
compatibleModules
);
if (isSubscribed) {
setJobs(compositeSecurityJobs);
}
} catch (error) {
if (isSubscribed) {
addError(error, { title: i18n.SIEM_JOB_FETCH_FAILURE });
}
}
}
if (isSubscribed) {
setLoading(false);
}
}
fetchSecurityJobIdsFromGroupsData();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [refetchData, isMlAdmin, isLicensed, siemDefaultIndex, addError, http]);
return { isLicensed, isMlAdmin, jobs, loading };
};

View file

@ -6,29 +6,29 @@
import {
composeModuleAndInstalledJobs,
createSiemJobs,
createSecurityJobs,
getAugmentedFields,
getInstalledJobs,
getModuleJobs,
moduleToSiemJob,
} from './use_siem_jobs_helpers';
moduleToSecurityJob,
} from './use_security_jobs_helpers';
import {
checkRecognizerSuccess,
mockGetModuleResponse,
mockJobsSummaryResponse,
} from '../__mocks__/api';
} from '../api.mock';
// TODO: Expand test coverage
describe('useSiemJobsHelpers', () => {
describe('moduleToSiemJob', () => {
test('correctly converts module to SiemJob', () => {
const siemJob = moduleToSiemJob(
describe('useSecurityJobsHelpers', () => {
describe('moduleToSecurityJob', () => {
test('correctly converts module to SecurityJob', () => {
const securityJob = moduleToSecurityJob(
mockGetModuleResponse[0],
mockGetModuleResponse[0].jobs[0],
false
);
expect(siemJob).toEqual({
expect(securityJob).toEqual({
datafeedId: '',
datafeedIndices: [],
datafeedState: '',
@ -86,19 +86,19 @@ describe('useSiemJobsHelpers', () => {
const installedJobs = getInstalledJobs(mockJobsSummaryResponse, moduleJobs, [
'siem_auditbeat',
]);
const siemJobs = composeModuleAndInstalledJobs(installedJobs, moduleJobs);
expect(siemJobs.length).toEqual(6);
const securityJobs = composeModuleAndInstalledJobs(installedJobs, moduleJobs);
expect(securityJobs.length).toEqual(6);
});
});
describe('createSiemJobs', () => {
describe('createSecurityJobs', () => {
test('returns correct number of jobs when creating jobs with successful responses', () => {
const siemJobs = createSiemJobs(
const securityJobs = createSecurityJobs(
mockJobsSummaryResponse,
mockGetModuleResponse,
checkRecognizerSuccess
);
expect(siemJobs.length).toEqual(6);
expect(securityJobs.length).toEqual(6);
});
});
});

View file

@ -5,26 +5,26 @@
*/
import {
AugmentedSiemJobFields,
JobSummary,
AugmentedSecurityJobFields,
Module,
ModuleJob,
RecognizerModule,
SiemJob,
SecurityJob,
} from '../types';
import { mlModules } from '../ml_modules';
import { MlSummaryJob } from '../../../../../../ml/public';
/**
* Helper function for converting from ModuleJob -> SiemJob
* Helper function for converting from ModuleJob -> SecurityJob
* @param module
* @param moduleJob
* @param isCompatible
*/
export const moduleToSiemJob = (
export const moduleToSecurityJob = (
module: Module,
moduleJob: ModuleJob,
isCompatible: boolean
): SiemJob => {
): SecurityJob => {
return {
datafeedId: '',
datafeedIndices: [],
@ -46,7 +46,7 @@ export const moduleToSiemJob = (
};
/**
* Returns fields necessary to augment a ModuleJob to a SiemJob
* Returns fields necessary to augment a ModuleJob to a SecurityJob
*
* @param jobId
* @param moduleJobs
@ -54,9 +54,9 @@ export const moduleToSiemJob = (
*/
export const getAugmentedFields = (
jobId: string,
moduleJobs: SiemJob[],
moduleJobs: SecurityJob[],
compatibleModuleIds: string[]
): AugmentedSiemJobFields => {
): AugmentedSecurityJobFields => {
const moduleJob = moduleJobs.find((mj) => mj.id === jobId);
return moduleJob !== undefined
? {
@ -74,24 +74,27 @@ export const getAugmentedFields = (
};
/**
* Process Modules[] from the `get_module` ML API into SiemJobs[] by filtering to SIEM specific
* Process Modules[] from the `get_module` ML API into SecurityJobs[] by filtering to Security specific
* modules and unpacking jobs from each module
*
* @param modulesData
* @param compatibleModuleIds
*/
export const getModuleJobs = (modulesData: Module[], compatibleModuleIds: string[]): SiemJob[] =>
export const getModuleJobs = (
modulesData: Module[],
compatibleModuleIds: string[]
): SecurityJob[] =>
modulesData
.filter((module) => mlModules.includes(module.id))
.map((module) => [
...module.jobs.map((moduleJob) =>
moduleToSiemJob(module, moduleJob, compatibleModuleIds.includes(module.id))
moduleToSecurityJob(module, moduleJob, compatibleModuleIds.includes(module.id))
),
])
.flat();
/**
* Process JobSummary[] from the `jobs_summary` ML API into SiemJobs[] by filtering to to SIEM jobs
* Process data from the `jobs_summary` ML API into SecurityJobs[] by filtering to Security jobs
* and augmenting with moduleId/defaultIndexPattern/isCompatible
*
* @param jobSummaryData
@ -99,57 +102,57 @@ export const getModuleJobs = (modulesData: Module[], compatibleModuleIds: string
* @param compatibleModuleIds
*/
export const getInstalledJobs = (
jobSummaryData: JobSummary[],
moduleJobs: SiemJob[],
jobSummaryData: MlSummaryJob[],
moduleJobs: SecurityJob[],
compatibleModuleIds: string[]
): SiemJob[] =>
): SecurityJob[] =>
jobSummaryData
.filter(({ groups }) => groups.includes('siem') || groups.includes('security'))
.map<SiemJob>((jobSummary) => ({
.map<SecurityJob>((jobSummary) => ({
...jobSummary,
...getAugmentedFields(jobSummary.id, moduleJobs, compatibleModuleIds),
isInstalled: true,
}));
/**
* Combines installed jobs + moduleSiemJobs that don't overlap and sorts by name asc
* Combines installed jobs + moduleSecurityJobs that don't overlap and sorts by name asc
*
* @param installedJobs
* @param moduleSiemJobs
* @param moduleSecurityJobs
*/
export const composeModuleAndInstalledJobs = (
installedJobs: SiemJob[],
moduleSiemJobs: SiemJob[]
): SiemJob[] => {
installedJobs: SecurityJob[],
moduleSecurityJobs: SecurityJob[]
): SecurityJob[] => {
const installedJobsIds = installedJobs.map((installedJob) => installedJob.id);
return [
...installedJobs,
...moduleSiemJobs.filter((mj) => !installedJobsIds.includes(mj.id)),
...moduleSecurityJobs.filter((mj) => !installedJobsIds.includes(mj.id)),
].sort((a, b) => a.id.localeCompare(b.id));
};
/**
* Creates a list of SiemJobs by composing JobSummary jobs (installed jobs) and Module
* jobs (pre-packaged SIEM jobs) into a single job object that can be used throughout the SIEM app
* Creates a list of SecurityJobs by composing jobs summaries (installed jobs) and Module
* jobs (pre-packaged Security jobs) into a single job object that can be used throughout the Security app
*
* @param jobSummaryData
* @param modulesData
* @param compatibleModules
*/
export const createSiemJobs = (
jobSummaryData: JobSummary[],
export const createSecurityJobs = (
jobSummaryData: MlSummaryJob[],
modulesData: Module[],
compatibleModules: RecognizerModule[]
): SiemJob[] => {
): SecurityJob[] => {
// Create lookup of compatible modules
const compatibleModuleIds = compatibleModules.map((module) => module.id);
// Process modulesData: Filter to SIEM specific modules, and unpack jobs from modules
const moduleSiemJobs = getModuleJobs(modulesData, compatibleModuleIds);
// Process modulesData: Filter to Security specific modules, and unpack jobs from modules
const moduleSecurityJobs = getModuleJobs(modulesData, compatibleModuleIds);
// Process jobSummaryData: Filter to SIEM jobs, and augment with moduleId/defaultIndexPattern/isCompatible
const installedJobs = getInstalledJobs(jobSummaryData, moduleSiemJobs, compatibleModuleIds);
// Process jobSummaryData: Filter to Security jobs, and augment with moduleId/defaultIndexPattern/isCompatible
const installedJobs = getInstalledJobs(jobSummaryData, moduleSecurityJobs, compatibleModuleIds);
// Combine installed jobs + moduleSiemJobs that don't overlap, and sort by name asc
return composeModuleAndInstalledJobs(installedJobs, moduleSiemJobs);
// Combine installed jobs + moduleSecurityJobs that don't overlap, and sort by name asc
return composeModuleAndInstalledJobs(installedJobs, moduleSecurityJobs);
};

View file

@ -1,81 +0,0 @@
/*
* 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 { useEffect, useState } from 'react';
import { DEFAULT_INDEX_KEY } from '../../../../../common/constants';
import { checkRecognizer, getJobsSummary, getModules } from '../api';
import { SiemJob } from '../types';
import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions';
import { errorToToaster, useStateToaster } from '../../toasters';
import { useUiSetting$ } from '../../../lib/kibana';
import * as i18n from './translations';
import { createSiemJobs } from './use_siem_jobs_helpers';
import { useMlCapabilities } from './use_ml_capabilities';
type Return = [boolean, SiemJob[]];
/**
* Compiles a collection of SiemJobs, which are a list of all jobs relevant to the SIEM App. This
* includes all installed jobs in the `SIEM` ML group, and all jobs within ML Modules defined in
* ml_module (whether installed or not). Use the corresponding helper functions to filter the job
* list as necessary. E.g. installed jobs, running jobs, etc.
*
* @param refetchData
*/
export const useSiemJobs = (refetchData: boolean): Return => {
const [siemJobs, setSiemJobs] = useState<SiemJob[]>([]);
const [loading, setLoading] = useState(true);
const mlCapabilities = useMlCapabilities();
const userPermissions = hasMlUserPermissions(mlCapabilities);
const [siemDefaultIndex] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY);
const [, dispatchToaster] = useStateToaster();
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
setLoading(true);
async function fetchSiemJobIdsFromGroupsData() {
if (userPermissions) {
try {
// Batch fetch all installed jobs, ML modules, and check which modules are compatible with siemDefaultIndex
const [jobSummaryData, modulesData, compatibleModules] = await Promise.all([
getJobsSummary(abortCtrl.signal),
getModules({ signal: abortCtrl.signal }),
checkRecognizer({
indexPatternName: siemDefaultIndex,
signal: abortCtrl.signal,
}),
]);
const compositeSiemJobs = createSiemJobs(jobSummaryData, modulesData, compatibleModules);
if (isSubscribed) {
setSiemJobs(compositeSiemJobs);
}
} catch (error) {
if (isSubscribed) {
errorToToaster({ title: i18n.SIEM_JOB_FETCH_FAILURE, error, dispatchToaster });
}
}
}
if (isSubscribed) {
setLoading(false);
}
}
fetchSiemJobIdsFromGroupsData();
return () => {
isSubscribed = false;
abortCtrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refetchData, userPermissions]);
return [loading, siemJobs];
};

View file

@ -25,7 +25,7 @@ exports[`JobsTableFilters renders correctly against snapshot 1`] = `
<EuiFilterGroup>
<GroupsFilterPopover
onSelectedGroupsChanged={[Function]}
siemJobs={
securityJobs={
Array [
Object {
"datafeedId": "datafeed-linux_anomalous_network_activity_ecs",

View file

@ -7,20 +7,23 @@
import { mount, shallow } from 'enzyme';
import React from 'react';
import { GroupsFilterPopoverComponent } from './groups_filter_popover';
import { mockSiemJobs } from '../../__mocks__/api';
import { SiemJob } from '../../types';
import { mockSecurityJobs } from '../../api.mock';
import { SecurityJob } from '../../types';
import { cloneDeep } from 'lodash/fp';
describe('GroupsFilterPopover', () => {
let siemJobs: SiemJob[];
let securityJobs: SecurityJob[];
beforeEach(() => {
siemJobs = cloneDeep(mockSiemJobs);
securityJobs = cloneDeep(mockSecurityJobs);
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<GroupsFilterPopoverComponent siemJobs={siemJobs} onSelectedGroupsChanged={jest.fn()} />
<GroupsFilterPopoverComponent
securityJobs={securityJobs}
onSelectedGroupsChanged={jest.fn()}
/>
);
expect(wrapper).toMatchSnapshot();
});
@ -29,7 +32,7 @@ describe('GroupsFilterPopover', () => {
const mockOnSelectedGroupsChanged = jest.fn();
const wrapper = mount(
<GroupsFilterPopoverComponent
siemJobs={siemJobs}
securityJobs={securityJobs}
onSelectedGroupsChanged={mockOnSelectedGroupsChanged}
/>
);

View file

@ -15,30 +15,30 @@ import {
EuiSpacer,
} from '@elastic/eui';
import * as i18n from './translations';
import { SiemJob } from '../../types';
import { SecurityJob } from '../../types';
import { toggleSelectedGroup } from './toggle_selected_group';
interface GroupsFilterPopoverProps {
siemJobs: SiemJob[];
securityJobs: SecurityJob[];
onSelectedGroupsChanged: Dispatch<SetStateAction<string[]>>;
}
/**
* Popover for selecting which SiemJob groups to filter on. Component extracts unique groups and
* their counts from the provided SiemJobs. The 'siem' & 'security' groups are filtered out as all jobs will be
* Popover for selecting which SecurityJob groups to filter on. Component extracts unique groups and
* their counts from the provided SecurityJobs. The 'siem' & 'security' groups are filtered out as all jobs will be
* siem/security jobs
*
* @param siemJobs jobs to fetch groups from to display for filtering
* @param securityJobs jobs to fetch groups from to display for filtering
* @param onSelectedGroupsChanged change listener to be notified when group selection changes
*/
export const GroupsFilterPopoverComponent = ({
siemJobs,
securityJobs,
onSelectedGroupsChanged,
}: GroupsFilterPopoverProps) => {
const [isGroupPopoverOpen, setIsGroupPopoverOpen] = useState(false);
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const groups = siemJobs
const groups = securityJobs
.map((j) => j.groups)
.flat()
.filter((g) => g !== 'siem' && g !== 'security');

View file

@ -7,20 +7,20 @@
import { mount, shallow } from 'enzyme';
import React from 'react';
import { JobsTableFiltersComponent } from './jobs_table_filters';
import { SiemJob } from '../../types';
import { SecurityJob } from '../../types';
import { cloneDeep } from 'lodash/fp';
import { mockSiemJobs } from '../../__mocks__/api';
import { mockSecurityJobs } from '../../api.mock';
describe('JobsTableFilters', () => {
let siemJobs: SiemJob[];
let securityJobs: SecurityJob[];
beforeEach(() => {
siemJobs = cloneDeep(mockSiemJobs);
securityJobs = cloneDeep(mockSecurityJobs);
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<JobsTableFiltersComponent siemJobs={siemJobs} onFilterChanged={jest.fn()} />
<JobsTableFiltersComponent securityJobs={securityJobs} onFilterChanged={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
});
@ -28,7 +28,7 @@ describe('JobsTableFilters', () => {
test('when you click Elastic Jobs filter, state is updated and it is selected', () => {
const onFilterChanged = jest.fn();
const wrapper = mount(
<JobsTableFiltersComponent siemJobs={siemJobs} onFilterChanged={onFilterChanged} />
<JobsTableFiltersComponent securityJobs={securityJobs} onFilterChanged={onFilterChanged} />
);
wrapper.find('[data-test-subj="show-elastic-jobs-filter-button"]').first().simulate('click');
@ -45,7 +45,7 @@ describe('JobsTableFilters', () => {
test('when you click Custom Jobs filter, state is updated and it is selected', () => {
const onFilterChanged = jest.fn();
const wrapper = mount(
<JobsTableFiltersComponent siemJobs={siemJobs} onFilterChanged={onFilterChanged} />
<JobsTableFiltersComponent securityJobs={securityJobs} onFilterChanged={onFilterChanged} />
);
wrapper.find('[data-test-subj="show-custom-jobs-filter-button"]').first().simulate('click');
@ -62,7 +62,7 @@ describe('JobsTableFilters', () => {
test('when you click Custom Jobs filter once, then Elastic Jobs filter, state is updated and selected changed', () => {
const onFilterChanged = jest.fn();
const wrapper = mount(
<JobsTableFiltersComponent siemJobs={siemJobs} onFilterChanged={onFilterChanged} />
<JobsTableFiltersComponent securityJobs={securityJobs} onFilterChanged={onFilterChanged} />
);
wrapper.find('[data-test-subj="show-custom-jobs-filter-button"]').first().simulate('click');
@ -88,7 +88,7 @@ describe('JobsTableFilters', () => {
test('when you click Custom Jobs filter twice, state is updated and it is revert', () => {
const onFilterChanged = jest.fn();
const wrapper = mount(
<JobsTableFiltersComponent siemJobs={siemJobs} onFilterChanged={onFilterChanged} />
<JobsTableFiltersComponent securityJobs={securityJobs} onFilterChanged={onFilterChanged} />
);
wrapper.find('[data-test-subj="show-custom-jobs-filter-button"]').first().simulate('click');

View file

@ -15,11 +15,11 @@ import {
} from '@elastic/eui';
import { EuiSearchBarQuery } from '../../../../../timelines/components/open_timeline/types';
import * as i18n from './translations';
import { JobsFilters, SiemJob } from '../../types';
import { JobsFilters, SecurityJob } from '../../types';
import { GroupsFilterPopover } from './groups_filter_popover';
interface JobsTableFiltersProps {
siemJobs: SiemJob[];
securityJobs: SecurityJob[];
onFilterChanged: Dispatch<SetStateAction<JobsFilters>>;
}
@ -27,10 +27,13 @@ interface JobsTableFiltersProps {
* Collection of filters for filtering data within the JobsTable. Contains search bar, Elastic/Custom
* Jobs filter button toggle, and groups selection
*
* @param siemJobs jobs to fetch groups from to display for filtering
* @param securityJobs jobs to fetch groups from to display for filtering
* @param onFilterChanged change listener to be notified on filter changes
*/
export const JobsTableFiltersComponent = ({ siemJobs, onFilterChanged }: JobsTableFiltersProps) => {
export const JobsTableFiltersComponent = ({
securityJobs,
onFilterChanged,
}: JobsTableFiltersProps) => {
const [filterQuery, setFilterQuery] = useState<string>('');
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [showCustomJobs, setShowCustomJobs] = useState<boolean>(false);
@ -71,7 +74,10 @@ export const JobsTableFiltersComponent = ({ siemJobs, onFilterChanged }: JobsTab
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<GroupsFilterPopover siemJobs={siemJobs} onSelectedGroupsChanged={setSelectedGroups} />
<GroupsFilterPopover
securityJobs={securityJobs}
onSelectedGroupsChanged={setSelectedGroups}
/>
</EuiFilterGroup>
</EuiFlexItem>

View file

@ -9,22 +9,22 @@ import React from 'react';
import { JobSwitchComponent } from './job_switch';
import { cloneDeep } from 'lodash/fp';
import { mockSiemJobs } from '../__mocks__/api';
import { SiemJob } from '../types';
import { mockSecurityJobs } from '../api.mock';
import { SecurityJob } from '../types';
describe('JobSwitch', () => {
let siemJobs: SiemJob[];
let securityJobs: SecurityJob[];
let onJobStateChangeMock = jest.fn();
beforeEach(() => {
siemJobs = cloneDeep(mockSiemJobs);
securityJobs = cloneDeep(mockSecurityJobs);
onJobStateChangeMock = jest.fn();
});
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<JobSwitchComponent
job={siemJobs[0]}
isSiemJobsLoading={false}
job={securityJobs[0]}
isSecurityJobsLoading={false}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -34,8 +34,8 @@ describe('JobSwitch', () => {
test('should call onJobStateChange when the switch is clicked to be true/open', () => {
const wrapper = mount(
<JobSwitchComponent
job={siemJobs[0]}
isSiemJobsLoading={false}
job={securityJobs[0]}
isSecurityJobsLoading={false}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -57,8 +57,8 @@ describe('JobSwitch', () => {
test('should have a switch when it is not in the loading state', () => {
const wrapper = mount(
<JobSwitchComponent
isSiemJobsLoading={false}
job={siemJobs[0]}
isSecurityJobsLoading={false}
job={securityJobs[0]}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -68,8 +68,8 @@ describe('JobSwitch', () => {
test('should not have a switch when it is in the loading state', () => {
const wrapper = mount(
<JobSwitchComponent
isSiemJobsLoading={true}
job={siemJobs[0]}
isSecurityJobsLoading={true}
job={securityJobs[0]}
onJobStateChange={onJobStateChangeMock}
/>
);

View file

@ -12,7 +12,7 @@ import {
isJobFailed,
isJobStarted,
} from '../../../../../common/machine_learning/helpers';
import { SiemJob } from '../types';
import { SecurityJob } from '../types';
const StaticSwitch = styled(EuiSwitch)`
.euiSwitch__thumb,
@ -24,14 +24,14 @@ const StaticSwitch = styled(EuiSwitch)`
StaticSwitch.displayName = 'StaticSwitch';
export interface JobSwitchProps {
job: SiemJob;
isSiemJobsLoading: boolean;
onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => Promise<void>;
job: SecurityJob;
isSecurityJobsLoading: boolean;
onJobStateChange: (job: SecurityJob, latestTimestampMs: number, enable: boolean) => Promise<void>;
}
export const JobSwitchComponent = ({
job,
isSiemJobsLoading,
isSecurityJobsLoading,
onJobStateChange,
}: JobSwitchProps) => {
const [isLoading, setIsLoading] = useState(false);
@ -47,7 +47,7 @@ export const JobSwitchComponent = ({
return (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
{isSiemJobsLoading || isLoading || isJobLoading(job.jobState, job.datafeedState) ? (
{isSecurityJobsLoading || isLoading || isJobLoading(job.jobState, job.datafeedState) ? (
<EuiLoadingSpinner size="m" data-test-subj="job-switch-loader" />
) : (
<StaticSwitch

View file

@ -7,17 +7,17 @@
import { shallow, mount } from 'enzyme';
import React from 'react';
import { JobsTableComponent } from './jobs_table';
import { mockSiemJobs } from '../__mocks__/api';
import { mockSecurityJobs } from '../api.mock';
import { cloneDeep } from 'lodash/fp';
import { SiemJob } from '../types';
import { SecurityJob } from '../types';
jest.mock('../../../lib/kibana');
describe('JobsTableComponent', () => {
let siemJobs: SiemJob[];
let securityJobs: SecurityJob[];
let onJobStateChangeMock = jest.fn();
beforeEach(() => {
siemJobs = cloneDeep(mockSiemJobs);
securityJobs = cloneDeep(mockSecurityJobs);
onJobStateChangeMock = jest.fn();
});
@ -25,7 +25,7 @@ describe('JobsTableComponent', () => {
const wrapper = shallow(
<JobsTableComponent
isLoading={true}
jobs={siemJobs}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -36,7 +36,7 @@ describe('JobsTableComponent', () => {
const wrapper = mount(
<JobsTableComponent
isLoading={true}
jobs={siemJobs}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -46,11 +46,11 @@ describe('JobsTableComponent', () => {
});
test('should render the hyperlink with URI encodings which points specifically to the job id', () => {
siemJobs[0].id = 'job id with spaces';
securityJobs[0].id = 'job id with spaces';
const wrapper = mount(
<JobsTableComponent
isLoading={true}
jobs={siemJobs}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -63,7 +63,7 @@ describe('JobsTableComponent', () => {
const wrapper = mount(
<JobsTableComponent
isLoading={false}
jobs={siemJobs}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -73,14 +73,14 @@ describe('JobsTableComponent', () => {
.simulate('click', {
target: { checked: true },
});
expect(onJobStateChangeMock.mock.calls[0]).toEqual([siemJobs[0], 1571022859393, true]);
expect(onJobStateChangeMock.mock.calls[0]).toEqual([securityJobs[0], 1571022859393, true]);
});
test('should have a switch when it is not in the loading state', () => {
const wrapper = mount(
<JobsTableComponent
isLoading={false}
jobs={siemJobs}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
);
@ -91,7 +91,7 @@ describe('JobsTableComponent', () => {
const wrapper = mount(
<JobsTableComponent
isLoading={true}
jobs={siemJobs}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
);

View file

@ -25,7 +25,7 @@ import styled from 'styled-components';
import { useBasePath } from '../../../lib/kibana';
import * as i18n from './translations';
import { JobSwitch } from './job_switch';
import { SiemJob } from '../types';
import { SecurityJob } from '../types';
const JobNameWrapper = styled.div`
margin: 5px 0;
@ -38,12 +38,12 @@ const truncateThreshold = 200;
const getJobsTableColumns = (
isLoading: boolean,
onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => Promise<void>,
onJobStateChange: (job: SecurityJob, latestTimestampMs: number, enable: boolean) => Promise<void>,
basePath: string
) => [
{
name: i18n.COLUMN_JOB_NAME,
render: ({ id, description }: SiemJob) => (
render: ({ id, description }: SecurityJob) => (
<JobNameWrapper>
<EuiLink
data-test-subj="jobs-table-link"
@ -62,7 +62,7 @@ const getJobsTableColumns = (
},
{
name: i18n.COLUMN_GROUPS,
render: ({ groups }: SiemJob) => (
render: ({ groups }: SecurityJob) => (
<EuiFlexGroup wrap responsive={true} gutterSize="xs">
{groups.map((group) => (
<EuiFlexItem grow={false} key={group}>
@ -76,9 +76,13 @@ const getJobsTableColumns = (
{
name: i18n.COLUMN_RUN_JOB,
render: (job: SiemJob) =>
render: (job: SecurityJob) =>
job.isCompatible ? (
<JobSwitch job={job} isSiemJobsLoading={isLoading} onJobStateChange={onJobStateChange} />
<JobSwitch
job={job}
isSecurityJobsLoading={isLoading}
onJobStateChange={onJobStateChange}
/>
) : (
<EuiIcon aria-label="Warning" size="s" type="alert" color="warning" />
),
@ -87,13 +91,16 @@ const getJobsTableColumns = (
} as const,
];
const getPaginatedItems = (items: SiemJob[], pageIndex: number, pageSize: number): SiemJob[] =>
items.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize);
const getPaginatedItems = (
items: SecurityJob[],
pageIndex: number,
pageSize: number
): SecurityJob[] => items.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize);
export interface JobTableProps {
isLoading: boolean;
jobs: SiemJob[];
onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => Promise<void>;
jobs: SecurityJob[];
onJobStateChange: (job: SecurityJob, latestTimestampMs: number, enable: boolean) => Promise<void>;
}
export const JobsTableComponent = ({ isLoading, jobs, onJobStateChange }: JobTableProps) => {

View file

@ -12,19 +12,17 @@ import styled from 'styled-components';
import { useKibana } from '../../lib/kibana';
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry';
import { hasMlAdminPermissions } from '../../../../common/machine_learning/has_ml_admin_permissions';
import { errorToToaster, useStateToaster, ActionToaster } from '../toasters';
import { setupMlJob, startDatafeeds, stopDatafeeds } from './api';
import { filterJobs } from './helpers';
import { useSiemJobs } from './hooks/use_siem_jobs';
import { JobsTableFilters } from './jobs_table/filters/jobs_table_filters';
import { JobsTable } from './jobs_table/jobs_table';
import { ShowingCount } from './jobs_table/showing_count';
import { PopoverDescription } from './popover_description';
import * as i18n from './translations';
import { JobsFilters, SiemJob } from './types';
import { JobsFilters, SecurityJob } from './types';
import { UpgradeContents } from './upgrade_contents';
import { useMlCapabilities } from './hooks/use_ml_capabilities';
import { useSecurityJobs } from './hooks/use_security_jobs';
const PopoverContentsDiv = styled.div`
max-width: 684px;
@ -87,24 +85,25 @@ export const MlPopover = React.memo(() => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [filterProperties, setFilterProperties] = useState(defaultFilterProps);
const [isLoadingSiemJobs, siemJobs] = useSiemJobs(refreshToggle);
const { isMlAdmin, isLicensed, loading: isLoadingSecurityJobs, jobs } = useSecurityJobs(
refreshToggle
);
const [, dispatchToaster] = useStateToaster();
const capabilities = useMlCapabilities();
const docLinks = useKibana().services.docLinks;
const handleJobStateChange = useCallback(
(job: SiemJob, latestTimestampMs: number, enable: boolean) =>
(job: SecurityJob, latestTimestampMs: number, enable: boolean) =>
enableDatafeed(job, latestTimestampMs, enable, dispatch, dispatchToaster),
[dispatch, dispatchToaster]
);
const filteredJobs = filterJobs({
jobs: siemJobs,
jobs,
...filterProperties,
});
const incompatibleJobCount = siemJobs.filter((j) => !j.isCompatible).length;
const incompatibleJobCount = jobs.filter((j) => !j.isCompatible).length;
if (!capabilities.isPlatinumOrTrialLicense) {
if (!isLicensed) {
// If the user does not have platinum show upgrade UI
return (
<EuiPopover
@ -127,7 +126,7 @@ export const MlPopover = React.memo(() => {
<UpgradeContents />
</EuiPopover>
);
} else if (hasMlAdminPermissions(capabilities)) {
} else if (isMlAdmin) {
// If the user has Platinum License & ML Admin Permissions, show Anomaly Detection button & full config UI
return (
<EuiPopover
@ -156,7 +155,7 @@ export const MlPopover = React.memo(() => {
<EuiSpacer />
<JobsTableFilters siemJobs={siemJobs} onFilterChanged={setFilterProperties} />
<JobsTableFilters securityJobs={jobs} onFilterChanged={setFilterProperties} />
<ShowingCount filterResultsLength={filteredJobs.length} />
@ -194,7 +193,7 @@ export const MlPopover = React.memo(() => {
)}
<JobsTable
isLoading={isLoadingSiemJobs || isLoading}
isLoading={isLoadingSecurityJobs || isLoading}
jobs={filteredJobs}
onJobStateChange={handleJobStateChange}
/>
@ -209,7 +208,7 @@ export const MlPopover = React.memo(() => {
// Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch
const enableDatafeed = async (
job: SiemJob,
job: SecurityJob,
latestTimestampMs: number,
enable: boolean,
dispatch: Dispatch<Action>,
@ -257,7 +256,7 @@ const enableDatafeed = async (
dispatch({ type: 'refresh' });
};
const submitTelemetry = (job: SiemJob, enabled: boolean) => {
const submitTelemetry = (job: SecurityJob, enabled: boolean) => {
// Report type of job enabled/disabled
track(
METRIC_TYPE.COUNT,

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AuditMessageBase } from '../../../../../ml/public';
import { MlError } from '../ml/types';
import { MlSummaryJob } from '../../../../../ml/public';
export interface Group {
id: string;
@ -98,28 +98,6 @@ export interface MlSetupArgs {
prefix?: string;
}
/**
* Representation of an ML Job as returned from the `ml/jobs/jobs_summary` API
*/
export interface JobSummary {
auditMessage?: AuditMessageBase;
datafeedId: string;
datafeedIndices: string[];
datafeedState: string;
description: string;
earliestTimestampMs?: number;
latestResultsTimestampMs?: number;
groups: string[];
hasDatafeed: boolean;
id: string;
isSingleMetricViewerJob: boolean;
jobState: string;
latestTimestampMs?: number;
memory_status: string;
nodeName?: string;
processed_record_count: number;
}
export interface Detector {
detector_description: string;
function: string;
@ -133,10 +111,10 @@ export interface CustomURL {
}
/**
* Representation of an ML Job as used by the SIEM App -- a composition of ModuleJob and JobSummary
* Representation of an ML Job as used by the SIEM App -- a composition of ModuleJob and MlSummaryJob
* that includes necessary metadata like moduleName, defaultIndexPattern, etc.
*/
export interface SiemJob extends JobSummary {
export interface SecurityJob extends MlSummaryJob {
moduleId: string;
defaultIndexPattern: string;
isCompatible: boolean;
@ -144,7 +122,7 @@ export interface SiemJob extends JobSummary {
isElasticJob: boolean;
}
export interface AugmentedSiemJobFields {
export interface AugmentedSecurityJobFields {
moduleId: string;
defaultIndexPattern: string;
isCompatible: boolean;

View file

@ -9,7 +9,7 @@ import React, { useEffect } from 'react';
import { DEFAULT_ANOMALY_SCORE } from '../../../../../common/constants';
import { AnomaliesQueryTabBodyProps } from './types';
import { getAnomaliesFilterQuery } from './utils';
import { useSiemJobs } from '../../../components/ml_popover/hooks/use_siem_jobs';
import { useInstalledSecurityJobs } from '../../../components/ml/hooks/use_installed_security_jobs';
import { useUiSetting$ } from '../../../lib/kibana';
import { MatrixHistogramContainer } from '../../../components/matrix_histogram';
import { histogramConfigs } from './histogram_configs';
@ -38,13 +38,13 @@ export const AnomaliesQueryTabBody = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [, siemJobs] = useSiemJobs(true);
const { jobs } = useInstalledSecurityJobs();
const [anomalyScore] = useUiSetting$<number>(DEFAULT_ANOMALY_SCORE);
const mergedFilterQuery = getAnomaliesFilterQuery(
filterQuery,
anomaliesFilterQuery,
siemJobs,
jobs,
anomalyScore,
flowTarget,
ip

View file

@ -6,21 +6,20 @@
import deepmerge from 'deepmerge';
import { MlSummaryJob } from '../../../../../../ml/public';
import { ESTermQuery } from '../../../../../common/typed_json';
import { createFilter } from '../../helpers';
import { SiemJob } from '../../../components/ml_popover/types';
import { FlowTarget } from '../../../../graphql/types';
export const getAnomaliesFilterQuery = (
filterQuery: string | ESTermQuery | undefined,
anomaliesFilterQuery: object = {},
siemJobs: SiemJob[] = [],
securityJobs: MlSummaryJob[] = [],
anomalyScore: number,
flowTarget?: FlowTarget,
ip?: string
): string => {
const siemJobIds = siemJobs
.filter((job) => job.isInstalled)
const securityJobIds = securityJobs
.map((job) => job.id)
.map((jobId) => ({
match_phrase: {
@ -38,7 +37,7 @@ export const getAnomaliesFilterQuery = (
filter: [
{
bool: {
should: siemJobIds,
should: securityJobIds,
minimum_should_match: 1,
},
},

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
const createAppToastsMock = () => ({
addError: jest.fn(),
addSuccess: jest.fn(),
});
export const useAppToastsMock = {
create: createAppToastsMock,
};

View file

@ -17,6 +17,7 @@ export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() =>
export const useKibana = jest.fn(createUseKibanaMock());
export const useUiSetting = jest.fn(createUseUiSettingMock());
export const useUiSetting$ = jest.fn(createUseUiSetting$Mock());
export const useHttp = jest.fn(() => useKibana().services.http);
export const useTimeZone = jest.fn();
export const useDateFormat = jest.fn();
export const useBasePath = jest.fn(() => '/test/base/path');

View file

@ -6,8 +6,10 @@
import { coreMock } from '../../../../../../src/core/public/mocks';
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import { securityMock } from '../../../../../plugins/security/public/mocks';
export const createKibanaCoreStartMock = () => coreMock.createStart();
export const createKibanaPluginsStartMock = () => ({
data: dataPluginMock.createStartContract(),
security: securityMock.createSetup(),
});

View file

@ -96,28 +96,10 @@ export const createUseKibanaMock = () => {
export const createStartServices = () => {
const core = createKibanaCoreStartMock();
const plugins = createKibanaPluginsStartMock();
const security = {
authc: {
getCurrentUser: jest.fn(),
areAPIKeysEnabled: jest.fn(),
},
sessionTimeout: {
start: jest.fn(),
stop: jest.fn(),
extend: jest.fn(),
},
license: {
isEnabled: jest.fn(),
getFeatures: jest.fn(),
features$: jest.fn(),
},
__legacyCompat: { logoutUrl: 'logoutUrl', tenant: 'tenant' },
};
const services = ({
...core,
...plugins,
security,
} as unknown) as StartServices;
return services;

View file

@ -38,7 +38,7 @@ import {
buildRuleTypeDescription,
buildThresholdDescription,
} from './helpers';
import { useSiemJobs } from '../../../../common/components/ml_popover/hooks/use_siem_jobs';
import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs';
import { buildMlJobDescription } from './ml_job_description';
import { buildActionsDescription } from './actions_description';
import { buildThrottleDescription } from './throttle_description';
@ -67,7 +67,7 @@ export const StepRuleDescriptionComponent: React.FC<StepRuleDescriptionProps> =
}) => {
const kibana = useKibana();
const [filterManager] = useState<FilterManager>(new FilterManager(kibana.services.uiSettings));
const [, siemJobs] = useSiemJobs(true);
const { jobs } = useSecurityJobs(false);
const keys = Object.keys(schema);
const listItems = keys.reduce((acc: ListItems[], key: string) => {
@ -77,7 +77,7 @@ export const StepRuleDescriptionComponent: React.FC<StepRuleDescriptionProps> =
buildMlJobDescription(
get(key, data) as string,
(get(key, schema) as { label: string }).label,
siemJobs
jobs
),
];
}

View file

@ -7,31 +7,14 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mockOpenedJob } from '../../../../common/components/ml_popover/api.mock';
import { MlJobDescription, AuditIcon, JobStatusBadge } from './ml_job_description';
jest.mock('../../../../common/lib/kibana');
const job = {
moduleId: 'moduleId',
defaultIndexPattern: 'defaultIndexPattern',
isCompatible: true,
isInstalled: true,
isElasticJob: true,
datafeedId: 'datafeedId',
datafeedIndices: [],
datafeedState: 'datafeedState',
description: 'description',
groups: [],
hasDatafeed: true,
id: 'id',
isSingleMetricViewerJob: false,
jobState: 'jobState',
memory_status: 'memory_status',
processed_record_count: 0,
};
jest.mock('../../../../common/lib/kibana');
describe('MlJobDescription', () => {
it('renders correctly', () => {
const wrapper = shallow(<MlJobDescription job={job} />);
const wrapper = shallow(<MlJobDescription job={mockOpenedJob} />);
expect(wrapper.find('[data-test-subj="machineLearningJobId"]')).toHaveLength(1);
});
@ -47,7 +30,7 @@ describe('AuditIcon', () => {
describe('JobStatusBadge', () => {
it('renders correctly', () => {
const wrapper = shallow(<JobStatusBadge job={job} />);
const wrapper = shallow(<JobStatusBadge job={mockOpenedJob} />);
expect(wrapper.find('EuiBadge')).toHaveLength(1);
});

View file

@ -8,9 +8,9 @@ import React from 'react';
import styled from 'styled-components';
import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { MlSummaryJob } from '../../../../../../ml/public';
import { isJobStarted } from '../../../../../common/machine_learning/helpers';
import { useKibana } from '../../../../common/lib/kibana';
import { SiemJob } from '../../../../common/components/ml_popover/types';
import { ListItems } from './types';
import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations';
@ -21,7 +21,7 @@ enum MessageLevels {
}
const AuditIconComponent: React.FC<{
message: SiemJob['auditMessage'];
message: MlSummaryJob['auditMessage'];
}> = ({ message }) => {
if (!message) {
return null;
@ -47,7 +47,7 @@ const AuditIconComponent: React.FC<{
export const AuditIcon = React.memo(AuditIconComponent);
const JobStatusBadgeComponent: React.FC<{ job: SiemJob }> = ({ job }) => {
const JobStatusBadgeComponent: React.FC<{ job: MlSummaryJob }> = ({ job }) => {
const isStarted = isJobStarted(job.jobState, job.datafeedState);
const color = isStarted ? 'secondary' : 'danger';
const text = isStarted ? ML_JOB_STARTED : ML_JOB_STOPPED;
@ -69,7 +69,7 @@ const Wrapper = styled.div`
overflow: hidden;
`;
const MlJobDescriptionComponent: React.FC<{ job: SiemJob }> = ({ job }) => {
const MlJobDescriptionComponent: React.FC<{ job: MlSummaryJob }> = ({ job }) => {
const jobUrl = useKibana().services.application.getUrlForApp(
`ml#/jobs?mlManagement=(jobId:${encodeURI(job.id)})`
);
@ -92,12 +92,12 @@ export const MlJobDescription = React.memo(MlJobDescriptionComponent);
export const buildMlJobDescription = (
jobId: string,
label: string,
siemJobs: SiemJob[]
jobs: MlSummaryJob[]
): ListItems => {
const siemJob = siemJobs.find((job) => job.id === jobId);
const job = jobs.find(({ id }) => id === jobId);
return {
title: label,
description: siemJob ? <MlJobDescription job={siemJob} /> : jobId,
description: job ? <MlJobDescription job={job} /> : jobId,
};
};

View file

@ -8,14 +8,14 @@ import React from 'react';
import { shallow } from 'enzyme';
import { MlJobSelect } from './index';
import { useSiemJobs } from '../../../../common/components/ml_popover/hooks/use_siem_jobs';
import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs';
import { useFormFieldMock } from '../../../../common/mock';
jest.mock('../../../../common/components/ml_popover/hooks/use_siem_jobs');
jest.mock('../../../../common/components/ml_popover/hooks/use_security_jobs');
jest.mock('../../../../common/lib/kibana');
describe('MlJobSelect', () => {
beforeAll(() => {
(useSiemJobs as jest.Mock).mockReturnValue([false, []]);
(useSecurityJobs as jest.Mock).mockReturnValue({ loading: false, jobs: [] });
});
it('renders correctly', () => {

View file

@ -19,7 +19,7 @@ import {
import styled from 'styled-components';
import { isJobStarted } from '../../../../../common/machine_learning/helpers';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports';
import { useSiemJobs } from '../../../../common/components/ml_popover/hooks/use_siem_jobs';
import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs';
import { useKibana } from '../../../../common/lib/kibana';
import {
ML_JOB_SELECT_PLACEHOLDER_TEXT,
@ -81,7 +81,7 @@ interface MlJobSelectProps {
export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], field }) => {
const jobId = field.value as string;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const [isLoading, siemJobs] = useSiemJobs(false);
const { loading, jobs } = useSecurityJobs(false);
const mlUrl = useKibana().services.application.getUrlForApp('ml');
const handleJobChange = useCallback(
(machineLearningJobId: string) => {
@ -96,7 +96,7 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
disabled: true,
};
const jobOptions = siemJobs.map((job) => ({
const jobOptions = jobs.map((job) => ({
value: job.id,
inputDisplay: job.id,
dropdownDisplay: <JobDisplay title={job.id} description={job.description} />,
@ -107,9 +107,9 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
const isJobRunning = useMemo(() => {
// If the selected job is not found in the list, it means the placeholder is selected
// and so we don't want to show the warning, thus isJobRunning will be true when 'job == null'
const job = siemJobs.find((j) => j.id === jobId);
const job = jobs.find(({ id }) => id === jobId);
return job == null || isJobStarted(job.jobState, job.datafeedState);
}, [siemJobs, jobId]);
}, [jobs, jobId]);
return (
<MlJobSelectEuiFlexGroup>
@ -126,7 +126,7 @@ export const MlJobSelect: React.FC<MlJobSelectProps> = ({ describedByIds = [], f
<EuiFlexItem>
<EuiSuperSelect
hasDividers
isLoading={isLoading}
isLoading={loading}
onChange={handleJobChange}
options={options}
valueOfSelected={jobId || 'placeholder'}

View file

@ -12,10 +12,11 @@ import deepEqual from 'fast-deep-equal';
import { DEFAULT_INDEX_KEY } from '../../../../../common/constants';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions';
import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/public';
import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules';
import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations';
import { useMlCapabilities } from '../../../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities';
import { useUiSetting$ } from '../../../../common/lib/kibana';
import {
filterRuleFieldsForType,
@ -187,7 +188,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
componentProps={{
describedByIds: ['detectionEngineStepDefineRuleType'],
isReadOnly: isUpdateView,
hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense,
hasValidLicense: hasMlLicense(mlCapabilities),
isMlAdmin: hasMlAdminPermissions(mlCapabilities),
}}
/>

View file

@ -47,8 +47,9 @@ import { getColumns, getMonitoringColumns } from './columns';
import { showRulesTable } from './helpers';
import { allRulesReducer, State } from './reducer';
import { RulesTableFilters } from './rules_table_filters/rules_table_filters';
import { useMlCapabilities } from '../../../../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities';
import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions';
import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license';
import { SecurityPageName } from '../../../../../app/types';
import { useFormatUrl } from '../../../../../common/components/link_to';
@ -145,8 +146,7 @@ export const AllRules = React.memo<AllRulesProps>(
const { formatUrl } = useFormatUrl(SecurityPageName.detections);
// TODO: Refactor license check + hasMlAdminPermissions to common check
const hasMlPermissions =
mlCapabilities.isPlatinumOrTrialLicense && hasMlAdminPermissions(mlCapabilities);
const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities);
const setRules = useCallback((newRules: Rule[], newPagination: Partial<PaginationOptions>) => {
dispatch({

View file

@ -71,8 +71,9 @@ import { RuleActionsOverflow } from '../../../../components/rules/rule_actions_o
import { RuleStatusFailedCallOut } from './status_failed_callout';
import { FailureHistory } from './failure_history';
import { RuleStatus } from '../../../../components/rules//rule_status';
import { useMlCapabilities } from '../../../../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../../../../common/components/ml/hooks/use_ml_capabilities';
import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions';
import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_license';
import { SecurityPageName } from '../../../../../app/types';
import { LinkButton } from '../../../../../common/components/links';
import { useFormatUrl } from '../../../../../common/components/link_to';
@ -161,8 +162,7 @@ export const RuleDetailsPageComponent: FC<PropsFromRedux> = ({
const { globalFullScreen } = useFullScreen();
// TODO: Refactor license check + hasMlAdminPermissions to common check
const hasMlPermissions =
mlCapabilities.isPlatinumOrTrialLicense && hasMlAdminPermissions(mlCapabilities);
const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities);
const ruleDetailTabs = getRuleDetailsTabs(rule);
// persist rule until refresh is complete

View file

@ -17,7 +17,7 @@ import { LastEventTime } from '../../../common/components/last_event_time';
import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider';
import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria';
import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions';
import { useMlCapabilities } from '../../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities';
import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime';
import { SiemNavigation } from '../../../common/components/navigation';
import { KpiHostsComponent } from '../../components/kpi_hosts';

View file

@ -34,7 +34,7 @@ import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { esQuery } from '../../../../../../src/plugins/data/public';
import { useMlCapabilities } from '../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../common/components/ml/hooks/use_ml_capabilities';
import { OverviewEmpty } from '../../overview/components/overview_empty';
import { Display } from './display';
import { HostsTabs } from './hosts_tabs';

View file

@ -30,7 +30,7 @@ import { DescriptionListStyled, OverviewWrapper } from '../../../common/componen
import { Loader } from '../../../common/components/loader';
import { Anomalies, NarrowDateRange } from '../../../common/components/ml/types';
import { AnomalyScores } from '../../../common/components/ml/score/anomaly_scores';
import { useMlCapabilities } from '../../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities';
import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions';
import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect';

View file

@ -7,7 +7,7 @@
import React, { useMemo } from 'react';
import { Route, Switch, RouteComponentProps, useHistory } from 'react-router-dom';
import { useMlCapabilities } from '../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../common/components/ml/hooks/use_ml_capabilities';
import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions';
import { FlowTarget } from '../../graphql/types';

View file

@ -23,7 +23,7 @@ import { HostItem } from '../../../graphql/types';
import { Loader } from '../../../common/components/loader';
import { IPDetailsLink } from '../../../common/components/links';
import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions';
import { useMlCapabilities } from '../../../common/components/ml_popover/hooks/use_ml_capabilities';
import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities';
import { AnomalyScores } from '../../../common/components/ml/score/anomaly_scores';
import { Anomalies, NarrowDateRange } from '../../../common/components/ml/types';
import { DescriptionListStyled, OverviewWrapper } from '../../../common/components/page';