diff --git a/x-pack/plugins/siem/public/components/ml/empty_ml_capabilities.ts b/x-pack/plugins/siem/common/machine_learning/empty_ml_capabilities.ts similarity index 76% rename from x-pack/plugins/siem/public/components/ml/empty_ml_capabilities.ts rename to x-pack/plugins/siem/common/machine_learning/empty_ml_capabilities.ts index 9c8610ccd628c5..0d6a13c108b04f 100644 --- a/x-pack/plugins/siem/public/components/ml/empty_ml_capabilities.ts +++ b/x-pack/plugins/siem/common/machine_learning/empty_ml_capabilities.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MlCapabilities } from './types'; +import { MlCapabilitiesResponse } from '../../../ml/common/types/capabilities'; -export const emptyMlCapabilities: MlCapabilities = { +export const emptyMlCapabilities: MlCapabilitiesResponse = { capabilities: { + canAccessML: false, + canGetAnnotations: false, + canCreateAnnotation: false, + canDeleteAnnotation: false, canGetJobs: false, canCreateJob: false, canDeleteJob: false, @@ -26,11 +30,8 @@ export const emptyMlCapabilities: MlCapabilities = { canCreateFilter: false, canDeleteFilter: false, canFindFileStructure: false, - canGetDataFrame: false, - canDeleteDataFrame: false, - canPreviewDataFrame: false, - canCreateDataFrame: false, - canStartStopDataFrame: false, + canCreateDatafeed: false, + canDeleteDatafeed: false, canGetDataFrameAnalytics: false, canDeleteDataFrameAnalytics: false, canCreateDataFrameAnalytics: false, diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.test.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.test.ts similarity index 96% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.test.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.test.ts index ee237b42bede99..9824ce1232cbe4 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.test.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.test.ts @@ -6,7 +6,7 @@ import { hasMlAdminPermissions } from './has_ml_admin_permissions'; import { cloneDeep } from 'lodash/fp'; -import { emptyMlCapabilities } from '../empty_ml_capabilities'; +import { emptyMlCapabilities } from './empty_ml_capabilities'; describe('has_ml_admin_permissions', () => { let mlCapabilities = cloneDeep(emptyMlCapabilities); diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.ts similarity index 79% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.ts index 6fe142cf8e5832..106e9aabbc711d 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.ts @@ -4,21 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../ml/common/types/capabilities'; -export const hasMlAdminPermissions = (capabilities: MlCapabilities): boolean => +export const hasMlAdminPermissions = (capabilities: MlCapabilitiesResponse): boolean => getDataFeedPermissions(capabilities) && getJobPermissions(capabilities) && getFilterPermissions(capabilities) && getCalendarPermissions(capabilities); -const getDataFeedPermissions = ({ capabilities }: MlCapabilities): boolean => +const getDataFeedPermissions = ({ capabilities }: MlCapabilitiesResponse): boolean => capabilities.canGetDatafeeds && capabilities.canStartStopDatafeed && capabilities.canUpdateDatafeed && capabilities.canPreviewDatafeed; -const getJobPermissions = ({ capabilities }: MlCapabilities): boolean => +const getJobPermissions = ({ capabilities }: MlCapabilitiesResponse): boolean => capabilities.canCreateJob && capabilities.canGetJobs && capabilities.canUpdateJob && @@ -27,8 +27,8 @@ const getJobPermissions = ({ capabilities }: MlCapabilities): boolean => capabilities.canCloseJob && capabilities.canForecastJob; -const getFilterPermissions = ({ capabilities }: MlCapabilities) => +const getFilterPermissions = ({ capabilities }: MlCapabilitiesResponse) => capabilities.canGetFilters && capabilities.canCreateFilter && capabilities.canDeleteFilter; -const getCalendarPermissions = ({ capabilities }: MlCapabilities) => +const getCalendarPermissions = ({ capabilities }: MlCapabilitiesResponse) => capabilities.canCreateCalendar && capabilities.canGetCalendars && capabilities.canDeleteCalendar; diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.test.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.test.ts similarity index 94% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.test.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.test.ts index e3804055f2abbd..4d58cda81d71c5 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.test.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.test.ts @@ -6,7 +6,7 @@ import { cloneDeep } from 'lodash/fp'; import { hasMlUserPermissions } from './has_ml_user_permissions'; -import { emptyMlCapabilities } from '../empty_ml_capabilities'; +import { emptyMlCapabilities } from './empty_ml_capabilities'; describe('has_ml_user_permissions', () => { let mlCapabilities = cloneDeep(emptyMlCapabilities); diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.ts similarity index 81% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.ts index 2d55b7d74f93c5..dd746e4737bbc8 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../ml/common/types/capabilities'; -export const hasMlUserPermissions = (capabilities: MlCapabilities): boolean => +export const hasMlUserPermissions = (capabilities: MlCapabilitiesResponse): boolean => capabilities.capabilities.canGetJobs && capabilities.capabilities.canGetDatafeeds && capabilities.capabilities.canGetCalendars; diff --git a/x-pack/plugins/siem/common/detection_engine/ml_helpers.test.ts b/x-pack/plugins/siem/common/machine_learning/helpers.test.ts similarity index 96% rename from x-pack/plugins/siem/common/detection_engine/ml_helpers.test.ts rename to x-pack/plugins/siem/common/machine_learning/helpers.test.ts index ba93b2e4b8a0d8..ce343f75933dcf 100644 --- a/x-pack/plugins/siem/common/detection_engine/ml_helpers.test.ts +++ b/x-pack/plugins/siem/common/machine_learning/helpers.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isJobStarted, isJobLoading, isJobFailed } from './ml_helpers'; +import { isJobStarted, isJobLoading, isJobFailed } from './helpers'; describe('isJobStarted', () => { test('returns false if only jobState is enabled', () => { diff --git a/x-pack/plugins/siem/common/detection_engine/ml_helpers.ts b/x-pack/plugins/siem/common/machine_learning/helpers.ts similarity index 95% rename from x-pack/plugins/siem/common/detection_engine/ml_helpers.ts rename to x-pack/plugins/siem/common/machine_learning/helpers.ts index e4158d08d448dc..fe3eb79a6f6109 100644 --- a/x-pack/plugins/siem/common/detection_engine/ml_helpers.ts +++ b/x-pack/plugins/siem/common/machine_learning/helpers.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleType } from './types'; +import { RuleType } from '../detection_engine/types'; // Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js const enabledStates = ['started', 'opened']; diff --git a/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts index d64bd3a64e941f..67efda67a20a32 100644 --- a/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts @@ -9,7 +9,7 @@ import { useState, useEffect } 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 '../permissions/has_ml_user_permissions'; +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'; diff --git a/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts b/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts index e69abc1a86e0eb..e6a792e779b0cc 100644 --- a/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts +++ b/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { InfluencerInput, MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../../../ml/public'; import { KibanaServices } from '../../../lib/kibana'; +import { InfluencerInput } from '../types'; export interface Body { jobIds: string[]; @@ -20,8 +21,8 @@ export interface Body { maxExamples: number; } -export const getMlCapabilities = async (signal: AbortSignal): Promise => { - return KibanaServices.get().http.fetch('/api/ml/ml_capabilities', { +export const getMlCapabilities = async (signal: AbortSignal): Promise => { + return KibanaServices.get().http.fetch('/api/ml/ml_capabilities', { method: 'GET', asSystemRequest: true, signal, diff --git a/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx b/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx index eee44abb44204d..9326c53b6064da 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx +++ b/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx @@ -6,14 +6,14 @@ import React, { useState, useEffect } from 'react'; -import { MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../../../ml/public'; +import { emptyMlCapabilities } from '../../../../common/machine_learning/empty_ml_capabilities'; import { getMlCapabilities } from '../api/get_ml_capabilities'; -import { emptyMlCapabilities } from '../empty_ml_capabilities'; import { errorToToaster, useStateToaster } from '../../toasters'; import * as i18n from './translations'; -interface MlCapabilitiesProvider extends MlCapabilities { +interface MlCapabilitiesProvider extends MlCapabilitiesResponse { capabilitiesFetched: boolean; } diff --git a/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx b/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx index 16bde076ef7636..3272042732dff5 100644 --- a/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import * as i18n from './translations'; import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_columns'; import { convertAnomaliesToHosts } from './convert_anomalies_to_hosts'; import { Loader } from '../../loader'; import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies'; import { AnomaliesHostTableProps } from '../types'; -import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { BasicTable } from './basic_table'; import { hostEquality } from './host_equality'; diff --git a/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx b/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx index bba6355f0b8b97..cc3b1196f8432f 100644 --- a/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import * as i18n from './translations'; 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 { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; import { BasicTable } from './basic_table'; import { networkEquality } from './network_equality'; import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type'; diff --git a/x-pack/plugins/siem/public/components/ml/types.ts b/x-pack/plugins/siem/public/components/ml/types.ts index 953fb9f761ea86..f70c7d3eb034c8 100644 --- a/x-pack/plugins/siem/public/components/ml/types.ts +++ b/x-pack/plugins/siem/public/components/ml/types.ts @@ -4,15 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Influencer } from '../../../../ml/public'; + import { HostsType } from '../../store/hosts/model'; import { NetworkType } from '../../store/network/model'; import { FlowTarget } from '../../graphql/types'; -export interface Influencer { - influencer_field_name: string; - influencer_field_values: string[]; -} - export interface Source { job_id: string; result_type: string; @@ -35,11 +32,6 @@ export interface Source { influencers: Influencer[]; } -export interface Influencer { - influencer_field_name: string; - influencer_field_values: string[]; -} - export interface CriteriaFields { fieldName: string; fieldValue: string; @@ -100,41 +92,6 @@ export type AnomaliesNetworkTableProps = HostOrNetworkProps & { flowTarget?: FlowTarget; }; -export interface MlCapabilities { - capabilities: { - canGetJobs: boolean; - canCreateJob: boolean; - canDeleteJob: boolean; - canOpenJob: boolean; - canCloseJob: boolean; - canForecastJob: boolean; - canGetDatafeeds: boolean; - canStartStopDatafeed: boolean; - canUpdateJob: boolean; - canUpdateDatafeed: boolean; - canPreviewDatafeed: boolean; - canGetCalendars: boolean; - canCreateCalendar: boolean; - canDeleteCalendar: boolean; - canGetFilters: boolean; - canCreateFilter: boolean; - canDeleteFilter: boolean; - canFindFileStructure: boolean; - canGetDataFrame: boolean; - canDeleteDataFrame: boolean; - canPreviewDataFrame: boolean; - canCreateDataFrame: boolean; - canStartStopDataFrame: boolean; - canGetDataFrameAnalytics: boolean; - canDeleteDataFrameAnalytics: boolean; - canCreateDataFrameAnalytics: boolean; - canStartStopDataFrameAnalytics: boolean; - }; - isPlatinumOrTrialLicense: boolean; - mlFeatureEnabledInSpace: boolean; - upgradeInProgress: boolean; -} - const sourceOrDestination = ['source.ip', 'destination.ip']; export const isDestinationOrSource = (value: string | null): value is DestinationOrSource => diff --git a/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx b/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx index 26ebfeb91629bc..0b8da6be57e1b5 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx @@ -7,10 +7,6 @@ import { mockSiemJobs } from './__mocks__/api'; import { filterJobs, getStablePatternTitles, searchFilter } from './helpers'; -jest.mock('../ml/permissions/has_ml_admin_permissions', () => ({ - hasMlAdminPermissions: () => true, -})); - describe('helpers', () => { describe('filterJobs', () => { test('returns all jobs when no filter is suplied', () => { diff --git a/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx b/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx index 7bcbf4afa10cc4..98e74208b3dcc1 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx @@ -9,7 +9,7 @@ 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 '../../ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { errorToToaster, useStateToaster } from '../../toasters'; import { useUiSetting$ } from '../../../lib/kibana'; diff --git a/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx b/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx index e7b14f2e80bf24..7de2f0fbfbc544 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx @@ -11,7 +11,7 @@ import { isJobLoading, isJobFailed, isJobStarted, -} from '../../../../common/detection_engine/ml_helpers'; +} from '../../../../common/machine_learning/helpers'; import { SiemJob } from '../types'; const StaticSwitch = styled(EuiSwitch)` diff --git a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx index 3c93e1c195cd7a..cf4ac87bdb5e77 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx @@ -11,10 +11,6 @@ import { MlPopover } from './ml_popover'; jest.mock('../../lib/kibana'); -jest.mock('../ml/permissions/has_ml_admin_permissions', () => ({ - hasMlAdminPermissions: () => true, -})); - describe('MlPopover', () => { test('shows upgrade popover on mouse click', () => { const wrapper = mountWithIntl(); diff --git a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx index 6ea5cba4b37e43..e7f7770ee87f80 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx @@ -12,7 +12,7 @@ import styled from 'styled-components'; import { useKibana } from '../../lib/kibana'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry'; -import { hasMlAdminPermissions } from '../ml/permissions/has_ml_admin_permissions'; +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'; diff --git a/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx b/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx index 4d0e6a737d303f..223a16fec77a0e 100644 --- a/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx +++ b/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx @@ -19,7 +19,7 @@ import { InspectButton, InspectButtonContainer } from '../../../inspect'; import { HostItem } from '../../../../graphql/types'; import { Loader } from '../../../loader'; import { IPDetailsLink } from '../../../links'; -import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; diff --git a/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx b/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx index 56b59ca97156f6..456deaac0fb154 100644 --- a/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx +++ b/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx @@ -31,7 +31,7 @@ import { Loader } from '../../../loader'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; -import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; import { InspectButton, InspectButtonContainer } from '../../../inspect'; interface OwnProps { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 16cdf8ff91a462..2bb12562c650a7 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -19,7 +19,7 @@ import { FormattedRelative } from '@kbn/i18n/react'; import * as H from 'history'; import React, { Dispatch } from 'react'; -import { isMlRule } from '../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { Rule, RuleStatus } from '../../../../containers/detection_engine/rules'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { FormattedDate } from '../../../../components/formatted_date'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 18ca4d42bd018d..d9a2fafd144bcb 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -48,7 +48,7 @@ import { showRulesTable } from './helpers'; import { allRulesReducer, State } from './reducer'; import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; import { useMlCapabilities } from '../../../../components/ml_popover/hooks/use_ml_capabilities'; -import { hasMlAdminPermissions } from '../../../../components/ml/permissions/has_ml_admin_permissions'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; const SORT_FIELD = 'enabled'; const initialState: State = { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx index 79993c37e549c3..33d3dbcba86312 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx @@ -8,7 +8,7 @@ import React from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; -import { isJobStarted } from '../../../../../../common/detection_engine/ml_helpers'; +import { isJobStarted } from '../../../../../../common/machine_learning/helpers'; import { useKibana } from '../../../../../lib/kibana'; import { SiemJob } from '../../../../../components/ml_popover/types'; import { ListItems } from './types'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx index 4fb9faaea711c0..c011c06e86542e 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; -import { isJobStarted } from '../../../../../../common/detection_engine/ml_helpers'; +import { isJobStarted } from '../../../../../../common/machine_learning/helpers'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; import { useKibana } from '../../../../../lib/kibana'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index 6f3d299da8d452..dc9a832f820ba7 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -16,7 +16,7 @@ import { EuiText, } from '@elastic/eui'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { RuleType } from '../../../../../../common/detection_engine/types'; import { FieldHook } from '../../../../../shared_imports'; import { useKibana } from '../../../../../lib/kibana'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index b6887badc56be5..3517c6fb21e695 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { IIndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; @@ -38,7 +38,7 @@ import { import { schema } from './schema'; import * as i18n from './translations'; import { filterRuleFieldsForType, RuleFields } from '../../create/helpers'; -import { hasMlAdminPermissions } from '../../../../../components/ml/permissions/has_ml_admin_permissions'; +import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions'; const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index 8915c5f0a224f9..08832c5dfe4f53 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -9,7 +9,7 @@ import { EuiText } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React from 'react'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { esKuery } from '../../../../../../../../../src/plugins/data/public'; import { FieldValueQueryBar } from '../query_bar'; import { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 7ad116c313361d..b912c182a7c658 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -11,7 +11,7 @@ import deepmerge from 'deepmerge'; import { NOTIFICATION_THROTTLE_NO_ACTIONS } from '../../../../../common/constants'; import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; import { RuleType } from '../../../../../common/detection_engine/types'; -import { isMlRule } from '../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { NewRule } from '../../../../containers/detection_engine/rules'; import { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 3e45c892e23ddf..6a43c217e5ff5b 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -69,7 +69,7 @@ import { RuleStatusFailedCallOut } from './status_failed_callout'; import { FailureHistory } from './failure_history'; import { RuleStatus } from '../components/rule_status'; import { useMlCapabilities } from '../../../../components/ml_popover/hooks/use_ml_capabilities'; -import { hasMlAdminPermissions } from '../../../../components/ml/permissions/has_ml_admin_permissions'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; enum RuleDetailTabs { signals = 'signals', diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 2ccbffd864070e..3dbcf3b2425cc6 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -11,7 +11,7 @@ import memoizeOne from 'memoize-one'; import { useLocation } from 'react-router-dom'; import { RuleAlertAction, RuleType } from '../../../../common/detection_engine/types'; -import { isMlRule } from '../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../common/machine_learning/helpers'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; diff --git a/x-pack/plugins/siem/public/pages/hosts/details/index.tsx b/x-pack/plugins/siem/public/pages/hosts/details/index.tsx index 730c93b43709c2..afed0fab0ade7c 100644 --- a/x-pack/plugins/siem/public/pages/hosts/details/index.tsx +++ b/x-pack/plugins/siem/public/pages/hosts/details/index.tsx @@ -15,7 +15,7 @@ import { HeaderPage } from '../../../components/header_page'; import { LastEventTime } from '../../../components/last_event_time'; import { AnomalyTableProvider } from '../../../components/ml/anomaly/anomaly_table_provider'; import { hostToCriteria } from '../../../components/ml/criteria/host_to_criteria'; -import { hasMlUserPermissions } from '../../../components/ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { useMlCapabilities } from '../../../components/ml_popover/hooks/use_ml_capabilities'; import { scoreIntervalToDateTime } from '../../../components/ml/score/score_interval_to_datetime'; import { SiemNavigation } from '../../../components/navigation'; diff --git a/x-pack/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/plugins/siem/public/pages/hosts/hosts.tsx index 2fbbc0d96a1e35..0e29d634d07a62 100644 --- a/x-pack/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/plugins/siem/public/pages/hosts/hosts.tsx @@ -14,7 +14,7 @@ import { UpdateDateRange } from '../../components/charts/common'; import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { LastEventTime } from '../../components/last_event_time'; -import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; import { SiemNavigation } from '../../components/navigation'; import { KpiHostsComponent } from '../../components/page/hosts'; import { manageQuery } from '../../components/page/manage_query'; diff --git a/x-pack/plugins/siem/public/pages/network/index.tsx b/x-pack/plugins/siem/public/pages/network/index.tsx index babc153823b5a0..412e51e74059e0 100644 --- a/x-pack/plugins/siem/public/pages/network/index.tsx +++ b/x-pack/plugins/siem/public/pages/network/index.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom'; import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities'; -import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; import { FlowTarget } from '../../graphql/types'; import { IPDetails } from './ip_details'; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index e6facf6f3b7a8b..473d183c8a8f26 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -5,6 +5,8 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { typicalPayload, getReadBulkRequest, @@ -19,9 +21,12 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('create_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -34,12 +39,13 @@ describe('create_rules_bulk', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules clients.alertsClient.create.mockResolvedValue(getResult()); // successful creation - createRulesBulkRoute(server.router); + createRulesBulkRoute(server.router, ml); }); describe('status codes', () => { @@ -64,16 +70,20 @@ describe('create_rules_bulk', () => { }); describe('unhappy paths', () => { - it('returns an error object if creating an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 error object if ML Authz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const response = await server.inject(createBulkMlRuleRequest(), context); expect(response.status).toEqual(200); expect(response.body).toEqual([ { error: { - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, rule_id: 'rule-1', }, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index cf841a9c88b32e..371faccfbe47c9 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -8,6 +8,9 @@ import uuid from 'uuid'; import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { createRules } from '../../rules/create_rules'; import { RuleAlertParamsRest } from '../../types'; import { readRules } from '../../rules/read_rules'; @@ -19,13 +22,12 @@ import { createBulkErrorObject, buildRouteValidation, buildSiemResponse, - validateLicenseForRuleType, } from '../utils'; import { createRulesBulkSchema } from '../schemas/create_rules_bulk_schema'; import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; -export const createRulesBulkRoute = (router: IRouter) => { +export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.post( { path: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`, @@ -47,6 +49,8 @@ export const createRulesBulkRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + const ruleDefinitions = request.body; const dupes = getDuplicates(ruleDefinitions, 'rule_id'); @@ -89,7 +93,7 @@ export const createRulesBulkRoute = (router: IRouter) => { } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + throwHttpError(await mlAuthz.validateRuleType(type)); const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index f15f47432f8389..afdcda7da251de 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -16,15 +16,19 @@ import { getFindResultWithSingleHit, createMlRuleRequest, } from '../__mocks__/request_responses'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; jest.mock('../../rules/update_rules_notifications'); +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('create_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -37,13 +41,14 @@ describe('create_rules', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no current rules clients.alertsClient.create.mockResolvedValue(getResult()); // creation succeeds clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // needed to transform - createRulesRoute(server.router); + createRulesRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -86,14 +91,18 @@ describe('create_rules', () => { expect(response.status).toEqual(200); }); - it('rejects the request if licensing is not platinum', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 if ML Authz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const response = await server.inject(createMlRuleRequest(), context); - expect(response.status).toEqual(400); + expect(response.status).toEqual(403); expect(response.body).toEqual({ - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }); }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index 6605b5abfcb09f..7cbb22221679a3 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -8,22 +8,20 @@ import uuid from 'uuid'; import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { createRules } from '../../rules/create_rules'; import { readRules } from '../../rules/read_rules'; import { RuleAlertParamsRest } from '../../types'; import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; -import { - buildRouteValidation, - transformError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; -export const createRulesRoute = (router: IRouter): void => { +export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void => { router.post( { path: DETECTION_ENGINE_RULES_URL, @@ -70,7 +68,6 @@ export const createRulesRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); const alertsClient = context.alerting?.getAlertsClient(); const clusterClient = context.core.elasticsearch.dataClient; const savedObjectsClient = context.core.savedObjects.client; @@ -80,6 +77,9 @@ export const createRulesRoute = (router: IRouter): void => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + throwHttpError(await mlAuthz.validateRuleType(type)); + const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); if (!indexExists) { diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 91685a68a60ae9..c33c917c2e9872 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -9,8 +9,6 @@ import { ruleIdsToNdJsonString, rulesToNdJsonString, getSimpleRuleWithId, - getSimpleRule, - getSimpleMlRule, } from '../__mocks__/utils'; import { getImportRulesRequest, @@ -22,10 +20,14 @@ import { getNonEmptyIndex, } from '../__mocks__/request_responses'; import { createMockConfig, requestContextMock, serverMock, requestMock } from '../__mocks__'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { importRulesRoute } from './import_rules_route'; import * as createRulesStreamFromNdJson from '../../rules/create_rules_stream_from_ndjson'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('import_rules_route', () => { beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -39,25 +41,20 @@ describe('import_rules_route', () => { let server: ReturnType; let request: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeEach(() => { - // jest carries state between mocked implementations when using - // spyOn. So now we're doing all three of these. - // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 - jest.resetAllMocks(); - jest.restoreAllMocks(); - jest.clearAllMocks(); - server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); config = createMockConfig(); const hapiStream = buildHapiStream(ruleIdsToNdJsonString(['rule-1'])); request = getImportRulesRequest(hapiStream); + ml = mlServicesMock.create(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no extant rules - importRulesRoute(server.router, config); + importRulesRoute(server.router, config, ml); }); describe('status codes', () => { @@ -83,11 +80,12 @@ describe('import_rules_route', () => { }); describe('unhappy paths', () => { - it('returns an error object if creating an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); - const rules = [getSimpleRule(), getSimpleMlRule('rule-2')]; - const hapiStreamWithMlRule = buildHapiStream(rulesToNdJsonString(rules)); - request = getImportRulesRequest(hapiStreamWithMlRule); + it('returns a 403 error object if ML Authz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const response = await server.inject(request, context); expect(response.status).toEqual(200); @@ -95,20 +93,19 @@ describe('import_rules_route', () => { errors: [ { error: { - message: - 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, - rule_id: 'rule-2', + rule_id: 'rule-1', }, ], success: false, - success_count: 1, + success_count: 0, }); }); test('returns error if createPromiseFromStreams throws error', async () => { - jest + const transformMock = jest .spyOn(createRulesStreamFromNdJson, 'createRulesStreamFromNdJson') .mockImplementation(() => { throw new Error('Test error'); @@ -116,6 +113,8 @@ describe('import_rules_route', () => { const response = await server.inject(request, context); expect(response.status).toEqual(500); expect(response.body).toEqual({ message: 'Test error', status_code: 500 }); + + transformMock.mockRestore(); }); test('returns an error if the index does not exist', async () => { diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 9ba083ae48086e..00010027f106ba 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -11,6 +11,9 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { createPromiseFromStreams } from '../../../../../../../../src/legacy/utils/streams'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { ConfigType } from '../../../../config'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { createRules } from '../../rules/create_rules'; import { ImportRulesRequestParams } from '../../rules/types'; import { readRules } from '../../rules/read_rules'; @@ -24,7 +27,6 @@ import { isImportRegular, transformError, buildSiemResponse, - validateLicenseForRuleType, } from '../utils'; import { ImportRuleAlertRest } from '../../types'; import { patchRules } from '../../rules/patch_rules'; @@ -38,7 +40,7 @@ type PromiseFromStreams = ImportRuleAlertRest | Error; const CHUNK_PARSED_OBJECT_SIZE = 10; -export const importRulesRoute = (router: IRouter, config: ConfigType) => { +export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupPlugins['ml']) => { router.post( { path: `${DETECTION_ENGINE_RULES_URL}/_import`, @@ -67,6 +69,8 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + const { filename } = request.body.file.hapi; const fileExtension = extname(filename).toLowerCase(); if (fileExtension !== '.ndjson') { @@ -148,10 +152,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { } = parsedRule; try { - validateLicenseForRuleType({ - license: context.licensing.license, - ruleType: type, - }); + throwHttpError(await mlAuthz.validateRuleType(type)); const rule = await readRules({ alertsClient, ruleId }); if (rule == null) { @@ -207,8 +208,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { timelineTitle, meta, filters, - id: undefined, - ruleId, + rule, index, interval, maxSignals, @@ -240,7 +240,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { resolve( createBulkErrorObject({ ruleId, - statusCode: 400, + statusCode: err.statusCode ?? 400, message: err.message, }) ); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index a1f39936dd674e..24b2d5631b3a7f 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -5,6 +5,8 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, typicalPayload, @@ -17,9 +19,12 @@ import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('patch_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -32,11 +37,12 @@ describe('patch_rules_bulk', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists clients.alertsClient.update.mockResolvedValue(getResult()); // update succeeds - patchRulesBulkRoute(server.router); + patchRulesBulkRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -90,21 +96,51 @@ describe('patch_rules_bulk', () => { expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); - it('rejects patching of an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('rejects patching a rule to ML if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'patch', path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, body: [typicalMlRulePayload()], }); + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'mocked validation message', + status_code: 403, + }, + rule_id: 'rule-1', + }, + ]); + }); + + it('rejects patching an existing ML rule if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); + const { type, ...payloadWithoutType } = typicalMlRulePayload(); + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [payloadWithoutType], + }); const response = await server.inject(request, context); + expect(response.status).toEqual(200); expect(response.body).toEqual([ { error: { - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, rule_id: 'rule-1', }, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 201e1f823b4cbb..69789fe9466221 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -6,13 +6,11 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { - transformBulkError, - buildRouteValidation, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { transformBulkError, buildRouteValidation, buildSiemResponse } from '../utils'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; import { patchRulesBulkSchema } from '../schemas/patch_rules_bulk_schema'; @@ -20,8 +18,9 @@ import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; import { patchRules } from '../../rules/patch_rules'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; +import { readRules } from '../../rules/read_rules'; -export const patchRulesBulkRoute = (router: IRouter) => { +export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.patch( { path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, @@ -42,6 +41,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); const rules = await Promise.all( request.body.map(async payloadRule => { @@ -81,10 +81,18 @@ export const patchRulesBulkRoute = (router: IRouter) => { const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { if (type) { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + // reject an unauthorized "promotion" to ML + throwHttpError(await mlAuthz.validateRuleType(type)); + } + + const existingRule = await readRules({ alertsClient, ruleId, id }); + if (existingRule?.params.type) { + // reject an unauthorized modification of an ML rule + throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); } const rule = await patchRules({ + rule: existingRule, alertsClient, description, enabled, @@ -99,8 +107,6 @@ export const patchRulesBulkRoute = (router: IRouter) => { timelineTitle, meta, filters, - id, - ruleId, index, interval, maxSignals, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index dbb0a3bb3e1dae..9ae7e83ef7989a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -5,6 +5,8 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getFindResultStatus, @@ -19,9 +21,12 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('patch_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -34,13 +39,14 @@ describe('patch_rules', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // existing rule clients.alertsClient.update.mockResolvedValue(getResult()); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // successful transform - patchRulesRoute(server.router); + patchRulesRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -112,8 +118,12 @@ describe('patch_rules', () => { ); }); - it('rejects patching a rule to ML if licensing is not platinum', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('rejects patching a rule to ML if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, @@ -121,10 +131,31 @@ describe('patch_rules', () => { }); const response = await server.inject(request, context); - expect(response.status).toEqual(400); + expect(response.status).toEqual(403); + expect(response.body).toEqual({ + message: 'mocked validation message', + status_code: 403, + }); + }); + + it('rejects patching an ML rule if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); + const { type, ...payloadWithoutType } = typicalMlRulePayload(); + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: payloadWithoutType, + }); + const response = await server.inject(request, context); + + expect(response.status).toEqual(403); expect(response.body).toEqual({ - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }); }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index 00ccd3059b38d9..ae23e0efc857d3 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -6,21 +6,20 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { patchRules } from '../../rules/patch_rules'; import { PatchRuleAlertParamsRest } from '../../rules/types'; import { patchRulesSchema } from '../schemas/patch_rules_schema'; -import { - buildRouteValidation, - transformError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; +import { readRules } from '../../rules/read_rules'; -export const patchRulesRoute = (router: IRouter) => { +export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.patch( { path: DETECTION_ENGINE_RULES_URL, @@ -68,10 +67,6 @@ export const patchRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { - if (type) { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); - } - const alertsClient = context.alerting?.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; @@ -79,6 +74,18 @@ export const patchRulesRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + if (type) { + // reject an unauthorized "promotion" to ML + throwHttpError(await mlAuthz.validateRuleType(type)); + } + + const existingRule = await readRules({ alertsClient, ruleId, id }); + if (existingRule?.params.type) { + // reject an unauthorized modification of an ML rule + throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); + } + const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); const rule = await patchRules({ alertsClient, @@ -95,8 +102,7 @@ export const patchRulesRoute = (router: IRouter) => { timelineTitle, meta, filters, - id, - ruleId, + rule: existingRule, index, interval, maxSignals, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 332a47d0c0fc25..e48c72ce9579e0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getResult, @@ -16,12 +19,14 @@ import { import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; import { BulkError } from '../utils'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('update_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -34,12 +39,13 @@ describe('update_rules_bulk', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); clients.alertsClient.update.mockResolvedValue(getResult()); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); - updateRulesBulkRoute(server.router); + updateRulesBulkRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -92,8 +98,12 @@ describe('update_rules_bulk', () => { expect(response.body).toEqual(expected); }); - it('returns an error object if creating an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 error object if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'put', path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, @@ -105,8 +115,8 @@ describe('update_rules_bulk', () => { expect(response.body).toEqual([ { error: { - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, rule_id: 'rule-1', }, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 6d8f2243787e87..11892898d214b6 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -6,22 +6,20 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { UpdateRuleAlertParamsRest } from '../../rules/types'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; -import { - buildRouteValidation, - transformBulkError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformBulkError, buildSiemResponse } from '../utils'; import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema'; import { updateRules } from '../../rules/update_rules'; import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; -export const updateRulesBulkRoute = (router: IRouter) => { +export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.put( { path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, @@ -43,6 +41,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); const rules = await Promise.all( request.body.map(async payloadRule => { @@ -83,7 +82,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + throwHttpError(await mlAuthz.validateRuleType(type)); const rule = await updateRules({ alertsClient, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 53c52153e84e6e..ce25a0204a6063 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { updateRulesRoute } from './update_rules_route'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getResult, @@ -19,11 +20,15 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; +import { updateRulesRoute } from './update_rules_route'; + +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); jest.mock('../../rules/update_rules_notifications'); describe('update_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -36,13 +41,14 @@ describe('update_rules', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists clients.alertsClient.update.mockResolvedValue(getResult()); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatusEmpty()); // successful transform - updateRulesRoute(server.router); + updateRulesRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -106,8 +112,12 @@ describe('update_rules', () => { }); }); - it('rejects the request if licensing is not adequate', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'put', path: DETECTION_ENGINE_RULES_URL, @@ -115,10 +125,10 @@ describe('update_rules', () => { }); const response = await server.inject(request, context); - expect(response.status).toEqual(400); + expect(response.status).toEqual(403); expect(response.body).toEqual({ - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }); }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index bfbeef8be2fea3..f15154a09657db 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -6,21 +6,19 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { UpdateRuleAlertParamsRest } from '../../rules/types'; import { updateRulesSchema } from '../schemas/update_rules_schema'; -import { - buildRouteValidation, - transformError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { updateRules } from '../../rules/update_rules'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; -export const updateRulesRoute = (router: IRouter) => { +export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.put( { path: DETECTION_ENGINE_RULES_URL, @@ -69,8 +67,6 @@ export const updateRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); - const alertsClient = context.alerting?.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; const siemClient = context.siem?.getSiemClient(); @@ -80,6 +76,9 @@ export const updateRulesRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + throwHttpError(await mlAuthz.validateRuleType(type)); + const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const rule = await updateRules({ alertsClient, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts index 25e76f367037a4..1c1bee58f0c973 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts @@ -8,7 +8,7 @@ import * as t from 'io-ts'; import { Either, left, fold } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { dependentRulesSchema, RequiredRulesSchema, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index 8af5df60569135..fdb1cd148c7fa0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -19,11 +19,9 @@ import { transformImportError, convertToSnakeCase, SiemResponseFactory, - validateLicenseForRuleType, } from './utils'; import { responseMock } from './__mocks__'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; -import { licensingMock } from '../../../../../licensing/server/mocks'; describe('utils', () => { beforeAll(() => { @@ -361,36 +359,4 @@ describe('utils', () => { ); }); }); - - describe('validateLicenseForRuleType', () => { - let licenseMock: ReturnType; - - beforeEach(() => { - licenseMock = licensingMock.createLicenseMock(); - }); - - it('throws a BadRequestError if operating on an ML Rule with an insufficient license', () => { - licenseMock.hasAtLeast.mockReturnValue(false); - - expect(() => - validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) - ).toThrowError(BadRequestError); - }); - - it('does not throw if operating on an ML Rule with a sufficient license', () => { - licenseMock.hasAtLeast.mockReturnValue(true); - - expect(() => - validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) - ).not.toThrowError(BadRequestError); - }); - - it('does not throw if operating on a query rule', () => { - licenseMock.hasAtLeast.mockReturnValue(false); - - expect(() => - validateLicenseForRuleType({ license: licenseMock, ruleType: 'query' }) - ).not.toThrowError(BadRequestError); - }); - }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts index 52493a9be9b8fb..9903840b99c6f0 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -7,17 +7,12 @@ import Boom from 'boom'; import Joi from 'joi'; import { has, snakeCase } from 'lodash/fp'; -import { i18n } from '@kbn/i18n'; import { RouteValidationFunction, KibanaResponseFactory, CustomHttpResponseOptions, } from '../../../../../../../src/core/server'; -import { ILicense } from '../../../../../licensing/server'; -import { MINIMUM_ML_LICENSE } from '../../../../common/constants'; -import { RuleType } from '../../../../common/detection_engine/types'; -import { isMlRule } from '../../../../common/detection_engine/ml_helpers'; import { BadRequestError } from '../errors/bad_request_error'; export interface OutputError { @@ -294,28 +289,3 @@ export const convertToSnakeCase = >( return { ...acc, [newKey]: obj[item] }; }, {}); }; - -/** - * Checks the current Kibana License against the rule under operation. - * - * @param license ILicense representing the user license - * @param ruleType the type of the current rule - * - * @throws BadRequestError if rule and license are incompatible - */ -export const validateLicenseForRuleType = ({ - license, - ruleType, -}: { - license: ILicense; - ruleType: RuleType; -}): void => { - if (isMlRule(ruleType) && !license.hasAtLeast(MINIMUM_ML_LICENSE)) { - const message = i18n.translate('xpack.siem.licensing.unsupportedMachineLearningMessage', { - defaultMessage: - 'Your license does not support machine learning. Please upgrade your license.', - }); - - throw new BadRequestError(message); - } -}; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts index c551eb164ee07f..a42500223012e1 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -19,14 +19,14 @@ describe('patchRules', () => { }); it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { - const rule = getResult(); - alertsClient.get.mockResolvedValue(getResult()); + const existingRule = getResult(); + const params = getResult().params; await patchRules({ alertsClient, savedObjectsClient, - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - ...rule.params, + rule: existingRule, + ...params, enabled: false, interval: '', name: '', @@ -35,23 +35,23 @@ describe('patchRules', () => { expect(alertsClient.disable).toHaveBeenCalledWith( expect.objectContaining({ - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + id: existingRule.id, }) ); }); it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { - const rule = getResult(); - alertsClient.get.mockResolvedValue({ + const existingRule = { ...getResult(), enabled: false, - }); + }; + const params = getResult().params; await patchRules({ alertsClient, savedObjectsClient, - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - ...rule.params, + rule: existingRule, + ...params, enabled: true, interval: '', name: '', @@ -60,13 +60,13 @@ describe('patchRules', () => { expect(alertsClient.enable).toHaveBeenCalledWith( expect.objectContaining({ - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + id: existingRule.id, }) ); }); it('calls the alertsClient with ML params', async () => { - alertsClient.get.mockResolvedValue(getMlResult()); + const existingRule = getMlResult(); const params = { ...getMlResult().params, anomalyThreshold: 55, @@ -76,7 +76,7 @@ describe('patchRules', () => { await patchRules({ alertsClient, savedObjectsClient, - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: existingRule, ...params, }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index da5e90ec14b0b5..6dfb72532afbbf 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -6,7 +6,6 @@ import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../alerting/server'; -import { readRules } from './read_rules'; import { PatchRuleParams } from './types'; import { addTags } from './add_tags'; import { calculateVersion, calculateName, calculateInterval } from './utils'; @@ -28,12 +27,11 @@ export const patchRules = async ({ filters, from, immutable, - id, - ruleId, index, interval, maxSignals, riskScore, + rule, name, severity, tags, @@ -47,7 +45,6 @@ export const patchRules = async ({ anomalyThreshold, machineLearningJobId, }: PatchRuleParams): Promise => { - const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { return null; } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts index b5dbfc92cf528e..217a966478e781 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -14,7 +14,7 @@ import { SavedObjectsClientContract, } from 'kibana/server'; import { AlertsClient, PartialAlert } from '../../../../../alerting/server'; -import { Alert } from '../../../../../alerting/common'; +import { Alert, SanitizedAlert } from '../../../../../alerting/common'; import { SIGNALS_ID } from '../../../../common/constants'; import { RuleAlertParams, RuleTypeParams, RuleAlertParamsRest } from '../types'; @@ -140,8 +140,8 @@ export interface Clients { alertsClient: AlertsClient; } -export type PatchRuleParams = Partial> & { - id: string | undefined | null; +export type PatchRuleParams = Partial> & { + rule: SanitizedAlert | null; savedObjectsClient: SavedObjectsClientContract; } & Clients; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts index e8fb4fa96ab512..2d77e9a707f746 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts @@ -6,7 +6,10 @@ import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; -import { mockPrepackagedRule } from '../routes/__mocks__/request_responses'; +import { + mockPrepackagedRule, + getFindResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; import { updatePrepackagedRules } from './update_prepacked_rules'; import { patchRules } from './patch_rules'; jest.mock('./patch_rules'); @@ -31,6 +34,7 @@ describe('updatePrepackagedRules', () => { ]; const outputIndex = 'outputIndex'; const prepackagedRule = mockPrepackagedRule(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); await updatePrepackagedRules( alertsClient, @@ -40,17 +44,8 @@ describe('updatePrepackagedRules', () => { ); expect(patchRules).toHaveBeenCalledWith( - expect.objectContaining({ - ruleId: 'rule-1', - }) - ); - expect(patchRules).not.toHaveBeenCalledWith( - expect.objectContaining({ + expect.not.objectContaining({ enabled: true, - }) - ); - expect(patchRules).not.toHaveBeenCalledWith( - expect.objectContaining({ actions, }) ); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts index 4c183c51d16eab..618dee26b4812a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -8,6 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { AlertsClient } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; import { PrepackagedRules } from '../types'; +import { readRules } from './read_rules'; export const updatePrepackagedRules = async ( alertsClient: AlertsClient, @@ -15,63 +16,66 @@ export const updatePrepackagedRules = async ( rules: PrepackagedRules[], outputIndex: string ): Promise => { - await rules.forEach(async rule => { - const { - description, - false_positives: falsePositives, - from, - immutable, - query, - language, - saved_id: savedId, - meta, - filters, - rule_id: ruleId, - index, - interval, - max_signals: maxSignals, - risk_score: riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - version, - note, - } = rule; + await Promise.all( + rules.map(async rule => { + const { + description, + false_positives: falsePositives, + from, + immutable, + query, + language, + saved_id: savedId, + meta, + filters, + rule_id: ruleId, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + note, + } = rule; - // Note: we do not pass down enabled as we do not want to suddenly disable - // or enable rules on the user when they were not expecting it if a rule updates - return patchRules({ - alertsClient, - description, - falsePositives, - from, - immutable, - query, - language, - outputIndex, - id: undefined, // We never have an id when updating from pre-packaged rules - savedId, - savedObjectsClient, - meta, - filters, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - version, - note, - }); - }); + const existingRule = await readRules({ alertsClient, ruleId, id: undefined }); + + // Note: we do not pass down enabled as we do not want to suddenly disable + // or enable rules on the user when they were not expecting it if a rule updates + return patchRules({ + alertsClient, + description, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + rule: existingRule, + savedId, + savedObjectsClient, + meta, + filters, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + note, + }); + }) + ); }; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index ca259b3581720b..6160f34faef3f4 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -8,7 +8,7 @@ import { performance } from 'perf_hooks'; import { Logger, KibanaRequest } from 'src/core/server'; import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; -import { isJobStarted, isMlRule } from '../../../../common/detection_engine/ml_helpers'; +import { isJobStarted, isMlRule } from '../../../../common/machine_learning/helpers'; import { SetupPlugins } from '../../../plugin'; import { buildEventsSearchQuery } from './build_events_query'; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/authz.test.ts b/x-pack/plugins/siem/server/lib/machine_learning/authz.test.ts new file mode 100644 index 00000000000000..93c3a74c713782 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/authz.test.ts @@ -0,0 +1,265 @@ +/* + * 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 { KibanaRequest } from '../../../../../../src/core/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { hasMlAdminPermissions } from '../../../common/machine_learning/has_ml_admin_permissions'; +import { mlServicesMock } from './mocks'; +import { hasMlLicense, isMlAdmin, buildMlAuthz } from './authz'; +import { licensingMock } from '../../../../licensing/server/mocks'; + +jest.mock('../../../common/machine_learning/has_ml_admin_permissions'); + +describe('isMlAdmin', () => { + it('returns true if hasMlAdminPermissions is true', async () => { + const mockMl = mlServicesMock.create(); + const request = httpServerMock.createKibanaRequest(); + (hasMlAdminPermissions as jest.Mock).mockReturnValue(true); + + expect(await isMlAdmin({ ml: mockMl, request })).toEqual(true); + }); + + it('returns false if hasMlAdminPermissions is false', async () => { + const mockMl = mlServicesMock.create(); + const request = httpServerMock.createKibanaRequest(); + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + + expect(await isMlAdmin({ ml: mockMl, request })).toEqual(false); + }); +}); + +describe('hasMlLicense', () => { + let licenseMock: ReturnType; + + beforeEach(() => { + licenseMock = licensingMock.createLicenseMock(); + }); + + it('returns false for an insufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(hasMlLicense(licenseMock)).toEqual(false); + }); + + it('returns true for a sufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(true); + + expect(hasMlLicense(licenseMock)).toEqual(true); + }); +}); + +describe('mlAuthz', () => { + let licenseMock: ReturnType; + let mlMock: ReturnType; + let request: KibanaRequest; + + beforeEach(() => { + licenseMock = licensingMock.createLicenseMock(); + mlMock = mlServicesMock.create(); + request = httpServerMock.createKibanaRequest(); + }); + + describe('#validateRuleType', () => { + it('is valid for a non-ML rule when ML plugin is unavailable', async () => { + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: undefined, + request, + }); + + const validation = await mlAuthz.validateRuleType('query'); + + expect(validation.valid).toEqual(true); + }); + + it('is invalid for an ML rule when ML plugin is unavailable', async () => { + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: undefined, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(false); + expect(validation.message).toEqual( + 'The machine learning plugin is not available. Try enabling the plugin.' + ); + }); + + it('is valid for a non-ML rule when license is insufficient', async () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('query'); + + expect(validation.valid).toEqual(true); + }); + + it('is invalid for an ML rule when license is insufficient', async () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(false); + expect(validation.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + }); + + it('is valid for a non-ML rule when not an ML Admin', async () => { + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('query'); + + expect(validation.valid).toEqual(true); + }); + + it('is invalid for an ML rule when not an ML Admin', async () => { + licenseMock.hasAtLeast.mockReturnValue(true); // prevents short-circuit on license check + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(false); + expect(validation.message).toEqual( + 'The current user is not a machine learning administrator.' + ); + }); + + it('is valid for an ML rule if ML available, license is sufficient, and an ML Admin', async () => { + licenseMock.hasAtLeast.mockReturnValue(true); + (hasMlAdminPermissions as jest.Mock).mockReturnValue(true); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(true); + expect(validation.message).toBeUndefined(); + }); + + it('only calls ml services once for multiple invocations', async () => { + const mockMlCapabilities = jest.fn(); + mlMock.mlSystemProvider.mockImplementation(() => ({ + mlInfo: jest.fn(), + mlSearch: jest.fn(), + mlCapabilities: mockMlCapabilities, + })); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + await mlAuthz.validateRuleType('machine_learning'); + await mlAuthz.validateRuleType('machine_learning'); + await mlAuthz.validateRuleType('machine_learning'); + + expect(mockMlCapabilities).toHaveBeenCalledTimes(1); + }); + + it('does not call ml services for non-ML rules', async () => { + const mockMlCapabilities = jest.fn(); + mlMock.mlSystemProvider.mockImplementation(() => ({ + mlInfo: jest.fn(), + mlSearch: jest.fn(), + mlCapabilities: mockMlCapabilities, + })); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + await mlAuthz.validateRuleType('query'); + await mlAuthz.validateRuleType('query'); + await mlAuthz.validateRuleType('query'); + + expect(mockMlCapabilities).not.toHaveBeenCalled(); + }); + + it('validates the same cache result per request if permissions change mid-stream', async () => { + licenseMock.hasAtLeast.mockReturnValueOnce(false); + licenseMock.hasAtLeast.mockReturnValueOnce(true); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validationFirst = await mlAuthz.validateRuleType('machine_learning'); + const validationSecond = await mlAuthz.validateRuleType('machine_learning'); + + expect(validationFirst.valid).toEqual(false); + expect(validationFirst.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + expect(validationSecond.valid).toEqual(false); + expect(validationSecond.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + }); + + it('will invalidate the cache result if the builder is called a second time after a license change', async () => { + licenseMock.hasAtLeast.mockReturnValueOnce(false); + licenseMock.hasAtLeast.mockReturnValueOnce(true); + (hasMlAdminPermissions as jest.Mock).mockReturnValueOnce(true); + + const mlAuthzFirst = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const mlAuthzSecond = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validationFirst = await mlAuthzFirst.validateRuleType('machine_learning'); + const validationSecond = await mlAuthzSecond.validateRuleType('machine_learning'); + + expect(validationFirst.valid).toEqual(false); + expect(validationFirst.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + expect(validationSecond.valid).toEqual(true); + expect(validationSecond.message).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/authz.ts b/x-pack/plugins/siem/server/lib/machine_learning/authz.ts new file mode 100644 index 00000000000000..fb74f46244361c --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/authz.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +import { KibanaRequest } from '../../../../../../src/core/server/'; +import { ILicense } from '../../../../licensing/server'; +import { MlPluginSetup } from '../../../../ml/server'; +import { SetupPlugins } from '../../plugin'; +import { MINIMUM_ML_LICENSE } from '../../../common/constants'; +import { hasMlAdminPermissions } from '../../../common/machine_learning/has_ml_admin_permissions'; +import { isMlRule } from '../../../common/machine_learning/helpers'; +import { RuleType } from '../../../common/detection_engine/types'; +import { Validation } from './validation'; +import { cache } from './cache'; + +export interface MlAuthz { + validateRuleType: (type: RuleType) => Promise; +} + +/** + * Builds ML authz services + * + * @param license A {@link ILicense} representing the user license + * @param ml {@link MlPluginSetup} ML services to fetch ML capabilities + * @param request A {@link KibanaRequest} representing the authenticated user + * + * @returns A {@link MLAuthz} service object + */ +export const buildMlAuthz = ({ + license, + ml, + request, +}: { + license: ILicense; + ml: SetupPlugins['ml']; + request: KibanaRequest; +}): MlAuthz => { + const cachedValidate = cache(() => validateMlAuthz({ license, ml, request })); + const validateRuleType = async (type: RuleType): Promise => { + if (!isMlRule(type)) { + return { valid: true, message: undefined }; + } else { + return cachedValidate(); + } + }; + + return { validateRuleType }; +}; + +/** + * Validates ML authorization for the current request + * + * @param license A {@link ILicense} representing the user license + * @param ml {@link MlPluginSetup} ML services to fetch ML capabilities + * @param request A {@link KibanaRequest} representing the authenticated user + * + * @returns A {@link Validation} validation + */ +export const validateMlAuthz = async ({ + license, + ml, + request, +}: { + license: ILicense; + ml: SetupPlugins['ml']; + request: KibanaRequest; +}): Promise => { + let message: string | undefined; + + if (ml == null) { + message = i18n.translate('xpack.siem.authz.mlUnavailable', { + defaultMessage: 'The machine learning plugin is not available. Try enabling the plugin.', + }); + } else if (!hasMlLicense(license)) { + message = i18n.translate('xpack.siem.licensing.unsupportedMachineLearningMessage', { + defaultMessage: + 'Your license does not support machine learning. Please upgrade your license.', + }); + } else if (!(await isMlAdmin({ ml, request }))) { + message = i18n.translate('xpack.siem.authz.userIsNotMlAdminMessage', { + defaultMessage: 'The current user is not a machine learning administrator.', + }); + } + + return { + valid: message === undefined, + message, + }; +}; + +/** + * Whether the license allows ML usage + * + * @param license A {@link ILicense} representing the user license + * + */ +export const hasMlLicense = (license: ILicense): boolean => license.hasAtLeast(MINIMUM_ML_LICENSE); + +/** + * Whether the requesting user is an ML Admin + * + * @param request A {@link KibanaRequest} representing the authenticated user + * @param ml {@link MlPluginSetup} ML services to fetch ML capabilities + * + */ +export const isMlAdmin = async ({ + request, + ml, +}: { + request: KibanaRequest; + ml: MlPluginSetup; +}): Promise => { + const scopedMlClient = ml.mlClient.asScoped(request).callAsCurrentUser; + const mlCapabilities = await ml.mlSystemProvider(scopedMlClient, request).mlCapabilities(); + return hasMlAdminPermissions(mlCapabilities); +}; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/cache.test.ts b/x-pack/plugins/siem/server/lib/machine_learning/cache.test.ts new file mode 100644 index 00000000000000..14e4cfe8ebdda8 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/cache.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { cache } from './cache'; + +describe('cache', () => { + it('does not call the function if not invoked', () => { + const fn = jest.fn(); + cache(fn); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('returns the function result', () => { + const fn = jest.fn().mockReturnValue('result'); + const cachedFn = cache(fn); + + expect(cachedFn()).toEqual('result'); + }); + + it('only calls the function once for multiple invocations', () => { + const fn = jest.fn(); + const cachedFn = cache(fn); + + cachedFn(); + cachedFn(); + cachedFn(); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('returns the function result on subsequent invocations', () => { + const fn = jest.fn().mockReturnValue('result'); + const cachedFn = cache(fn); + + expect([cachedFn(), cachedFn(), cachedFn()]).toEqual(['result', 'result', 'result']); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/cache.ts b/x-pack/plugins/siem/server/lib/machine_learning/cache.ts new file mode 100644 index 00000000000000..1a7b95f2c5af24 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/cache.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Caches the result of a function call + * + * @param fn the function to be invoked + * + * @returns A function that will invoke the given function on its first invocation, + * and then simply return the result on subsequent calls + */ +export const cache = (fn: () => T): (() => T) => { + let result: T | null = null; + + return () => { + if (result === null) { + result = fn(); + } + return result; + }; +}; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/mocks.ts b/x-pack/plugins/siem/server/lib/machine_learning/mocks.ts new file mode 100644 index 00000000000000..f044022d6db69e --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/mocks.ts @@ -0,0 +1,32 @@ +/* + * 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 { MlPluginSetup } from '../../../../ml/server'; +import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; + +const createMockClient = () => elasticsearchServiceMock.createClusterClient(); +const createMockMlSystemProvider = () => + jest.fn(() => ({ + mlCapabilities: jest.fn(), + })); + +export const mlServicesMock = { + create: () => + (({ + mlSystemProvider: createMockMlSystemProvider(), + mlClient: createMockClient(), + } as unknown) as jest.Mocked), +}; + +const mockValidateRuleType = jest.fn().mockResolvedValue({ valid: true, message: undefined }); +const createBuildMlAuthzMock = () => + jest.fn().mockReturnValue({ validateRuleType: mockValidateRuleType }); + +export const mlAuthzMock = { + create: () => ({ + buildMlAuthz: createBuildMlAuthzMock(), + }), +}; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/validation.test.ts b/x-pack/plugins/siem/server/lib/machine_learning/validation.test.ts new file mode 100644 index 00000000000000..effe59c073c59c --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/validation.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toHttpError, throwHttpError } from './validation'; + +describe('toHttpError', () => { + it('returns nothing if validation is valid', () => { + expect(toHttpError({ valid: true, message: undefined })).toBeUndefined(); + }); + + it('returns an HTTP error if validation is invalid', () => { + const error = toHttpError({ valid: false, message: 'validation message' }); + expect(error?.statusCode).toEqual(403); + expect(error?.message).toEqual('validation message'); + }); +}); + +describe('throwHttpError', () => { + it('does nothing if validation is valid', () => { + expect(() => throwHttpError({ valid: true, message: undefined })).not.toThrowError(); + }); + + it('throws an error if validation is invalid', () => { + let error; + try { + throwHttpError({ valid: false, message: 'validation failed' }); + } catch (e) { + error = e; + } + expect(error?.statusCode).toEqual(403); + expect(error?.message).toEqual('validation failed'); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/validation.ts b/x-pack/plugins/siem/server/lib/machine_learning/validation.ts new file mode 100644 index 00000000000000..eab85bbb510be2 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/validation.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +export interface Validation { + valid: boolean; + message: string | undefined; +} + +export class HttpAuthzError extends Error { + public readonly statusCode: number; + + constructor(message: string | undefined) { + super(message); + this.name = 'HttpAuthzError'; + this.statusCode = 403; + } +} + +export const toHttpError = (validation: Validation): HttpAuthzError | undefined => { + if (!validation.valid) { + return new HttpAuthzError(validation.message); + } +}; + +export const throwHttpError = (validation: Validation): void => { + const error = toHttpError(validation); + if (error) { + throw error; + } +}; diff --git a/x-pack/plugins/siem/server/plugin.ts b/x-pack/plugins/siem/server/plugin.ts index 3ef4b39bd0979c..d296ee94e89587 100644 --- a/x-pack/plugins/siem/server/plugin.ts +++ b/x-pack/plugins/siem/server/plugin.ts @@ -98,7 +98,8 @@ export class Plugin implements IPlugin { // Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules // All REST rule creation, deletion, updating, etc...... - createRulesRoute(router); + createRulesRoute(router, ml); readRulesRoute(router); - updateRulesRoute(router); - patchRulesRoute(router); + updateRulesRoute(router, ml); + patchRulesRoute(router, ml); deleteRulesRoute(router); findRulesRoute(router); addPrepackedRulesRoute(router); getPrepackagedRulesStatusRoute(router); - createRulesBulkRoute(router); - updateRulesBulkRoute(router); - patchRulesBulkRoute(router); + createRulesBulkRoute(router, ml); + updateRulesBulkRoute(router, ml); + patchRulesBulkRoute(router, ml); deleteRulesBulkRoute(router); createTimelinesRoute(router, config, security); updateTimelinesRoute(router, config, security); - importRulesRoute(router, config); + importRulesRoute(router, config, ml); exportRulesRoute(router, config); importTimelinesRoute(router, config, security);