diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index cddc8e363a49e..748b27fe13dd1 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -42,6 +42,25 @@ } } }, + "url": { + "dynamic": false, + "properties": { + "slug": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + } + } + }, "usage-counters": { "dynamic": false, "properties": { @@ -131,25 +150,6 @@ } } }, - "url": { - "dynamic": false, - "properties": { - "slug": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword" - } - } - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - } - } - }, "index-pattern": { "dynamic": false, "properties": { @@ -1407,6 +1407,14 @@ "dynamic": false, "properties": {} }, + "infrastructure-monitoring-log-view": { + "dynamic": false, + "properties": { + "name": { + "type": "text" + } + } + }, "canvas-element": { "dynamic": false, "properties": { @@ -2262,14 +2270,6 @@ } } }, - "infrastructure-monitoring-log-view": { - "dynamic": false, - "properties": { - "name": { - "type": "text" - } - } - }, "ml-job": { "properties": { "job_id": { @@ -2938,6 +2938,14 @@ } } }, + "risk-engine-configuration": { + "dynamic": false, + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "infrastructure-ui-source": { "dynamic": false, "properties": {} diff --git a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts index 2aa74086a73c2..75e093b047158 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts @@ -8,7 +8,10 @@ import { chunk } from 'lodash'; import type { ToolingLog } from '@kbn/tooling-log'; -import type { SavedObjectsBulkDeleteResponse } from '@kbn/core-saved-objects-api-server'; +import type { + SavedObjectsBulkDeleteResponse, + SavedObjectsFindResponse, +} from '@kbn/core-saved-objects-api-server'; import { KbnClientRequester, uriencode } from './kbn_client_requester'; @@ -30,6 +33,11 @@ interface SavedObjectResponse> { version?: string; } +interface FindOptions { + type: string; + space?: string; +} + interface GetOptions { type: string; id: string; @@ -152,6 +160,22 @@ export class KbnClientSavedObjects { return data; } + /** + * Find saved objects + */ + public async find>(options: FindOptions) { + this.log.debug('Find saved objects: %j', options); + + const { data } = await this.requester.request>({ + description: 'find saved objects', + path: options.space + ? uriencode`/s/${options.space}/internal/ftr/kbn_client_so/_find?type=${options.type}` + : uriencode`/internal/ftr/kbn_client_so/_find?type=${options.type}`, + method: 'GET', + }); + return data; + } + /** * Create a saved object */ diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 737522e973f98..c5ce6a38d9dc9 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -126,6 +126,7 @@ describe('checking migration metadata changes on all registered SO types', () => "osquery-pack-asset": "b14101d3172c4b60eb5404696881ce5275c84152", "osquery-saved-query": "44f1161e165defe3f9b6ad643c68c542a765fcdb", "query": "8db5d48c62d75681d80d82a42b5642f60d068202", + "risk-engine-configuration": "1b8b175e29ea5311408125c92c6247f502b2d79d", "rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f", "sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5", "search": "8d5184dd5b986d57250b6ffd9ae48a1925e4c7a3", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index feed5a05dcd31..3235c6cfa057e 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -104,6 +104,7 @@ const previouslyRegisteredTypes = [ 'search-telemetry', 'security-rule', 'security-solution-signals-migration', + 'risk-engine-configuration', 'server', 'siem-detection-engine-rule-actions', 'siem-detection-engine-rule-execution-info', diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 30888521d651e..32ac61f0d2711 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -246,6 +246,7 @@ describe('split .kibana index into multiple system indices', () => { "osquery-pack-asset", "osquery-saved-query", "query", + "risk-engine-configuration", "rules-settings", "sample-data-telemetry", "search-session", diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index a130dcbefc879..60f6e597306b1 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -253,6 +253,12 @@ export const RISK_SCORE_CREATE_STORED_SCRIPT = `${INTERNAL_RISK_SCORE_URL}/store export const RISK_SCORE_DELETE_STORED_SCRIPT = `${INTERNAL_RISK_SCORE_URL}/stored_scripts/delete`; export const RISK_SCORE_PREVIEW_URL = `${INTERNAL_RISK_SCORE_URL}/preview`; +export const RISK_ENGINE_URL = `${INTERNAL_RISK_SCORE_URL}/engine`; +export const RISK_ENGINE_STATUS_URL = `${RISK_ENGINE_URL}/status`; +export const RISK_ENGINE_INIT_URL = `${RISK_ENGINE_URL}/init`; +export const RISK_ENGINE_ENABLE_URL = `${RISK_ENGINE_URL}/enable`; +export const RISK_ENGINE_DISABLE_URL = `${RISK_ENGINE_URL}/disable`; + /** * Public Risk Score routes */ diff --git a/x-pack/plugins/security_solution/common/risk_engine/types.ts b/x-pack/plugins/security_solution/common/risk_engine/types.ts index 85911bbd3136d..087c9d1ed71e6 100644 --- a/x-pack/plugins/security_solution/common/risk_engine/types.ts +++ b/x-pack/plugins/security_solution/common/risk_engine/types.ts @@ -9,3 +9,17 @@ export enum RiskScoreEntity { host = 'host', user = 'user', } + +export enum RiskEngineStatus { + NOT_INSTALLED = 'NOT_INSTALLED', + DISABLED = 'DISABLED', + ENABLED = 'ENABLED', +} + +export interface InitRiskEngineResult { + legacyRiskEngineDisabled: boolean; + riskEngineResourcesInstalled: boolean; + riskEngineConfigurationCreated: boolean; + riskEngineEnabled: boolean; + errors: string[]; +} diff --git a/x-pack/plugins/security_solution/cypress/e2e/entity_analytics/entity_analytics_management_page.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/entity_analytics/entity_analytics_management_page.cy.ts index 2e6cd6db033ab..da06359fc789b 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/entity_analytics/entity_analytics_management_page.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/entity_analytics/entity_analytics_management_page.cy.ts @@ -12,83 +12,142 @@ import { USER_RISK_PREVIEW_TABLE, USER_RISK_PREVIEW_TABLE_ROWS, RISK_PREVIEW_ERROR, - RISK_PREVIEW_ERROR_BUTTON, LOCAL_QUERY_BAR_SELECTOR, + RISK_SCORE_ERROR_PANEL, + RISK_SCORE_STATUS, } from '../../screens/entity_analytics_management'; +import { deleteRiskScore, installRiskScoreModule } from '../../tasks/api_calls/risk_scores'; +import { RiskScoreEntity } from '../../tasks/risk_scores/common'; import { login, visit, visitWithoutDateRange } from '../../tasks/login'; import { cleanKibana } from '../../tasks/common'; import { ENTITY_ANALYTICS_MANAGEMENT_URL, ALERTS_URL } from '../../urls/navigation'; import { getNewRule } from '../../objects/rule'; import { createRule } from '../../tasks/api_calls/rules'; +import { + deleteConfiguration, + interceptRiskPreviewError, + interceptRiskPreviewSuccess, + interceptRiskInitError, +} from '../../tasks/api_calls/risk_engine'; import { updateDateRangeInLocalDatePickers } from '../../tasks/date_picker'; import { fillLocalSearchBar, submitLocalSearch } from '../../tasks/search_bar'; +import { + riskEngineStatusChange, + updateRiskEngine, + updateRiskEngineConfirm, + previewErrorButtonClick, +} from '../../tasks/entity_analytics'; + +describe( + 'Entity analytics management page', + { env: { ftrConfig: { enableExperimental: ['riskScoringRoutesEnabled'] } } }, + () => { + before(() => { + cleanKibana(); + cy.task('esArchiverLoad', 'all_users'); + }); -describe('Entity analytics management page', () => { - before(() => { - cleanKibana(); - cy.task('esArchiverLoad', 'all_users'); - }); + beforeEach(() => { + login(); + visitWithoutDateRange(ALERTS_URL); + createRule(getNewRule({ query: 'user.name:* or host.name:*', risk_score: 70 })); + deleteConfiguration(); + visit(ENTITY_ANALYTICS_MANAGEMENT_URL); + }); - beforeEach(() => { - login(); - visitWithoutDateRange(ALERTS_URL); - createRule(getNewRule({ query: 'user.name:* or host.name:*', risk_score: 70 })); - visit(ENTITY_ANALYTICS_MANAGEMENT_URL); - }); + after(() => { + cy.task('esArchiverUnload', 'all_users'); + }); - after(() => { - cy.task('esArchiverUnload', 'all_users'); - }); + it('renders page as expected', () => { + cy.get(PAGE_TITLE).should('have.text', 'Entity Risk Score'); + }); - it('renders page as expected', () => { - cy.get(PAGE_TITLE).should('have.text', 'Entity Risk Score'); - }); + describe('Risk preview', () => { + it('risk scores reacts on change in datepicker', () => { + const START_DATE = 'Jan 18, 2019 @ 20:33:29.186'; + const END_DATE = 'Jan 19, 2019 @ 20:33:29.186'; - describe('Risk preview', () => { - it('risk scores reacts on change in datepicker', () => { - const START_DATE = 'Jan 18, 2019 @ 20:33:29.186'; - const END_DATE = 'Jan 19, 2019 @ 20:33:29.186'; + cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); + cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); - cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); - cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); + updateDateRangeInLocalDatePickers(LOCAL_QUERY_BAR_SELECTOR, START_DATE, END_DATE); - updateDateRangeInLocalDatePickers(LOCAL_QUERY_BAR_SELECTOR, START_DATE, END_DATE); + cy.get(HOST_RISK_PREVIEW_TABLE).contains('No items found'); + cy.get(USER_RISK_PREVIEW_TABLE).contains('No items found'); + }); - cy.get(HOST_RISK_PREVIEW_TABLE).contains('No items found'); - cy.get(USER_RISK_PREVIEW_TABLE).contains('No items found'); - }); + it('risk scores reacts on change in search bar query', () => { + cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); + cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); + + fillLocalSearchBar('host.name: "test-host1"'); + submitLocalSearch(LOCAL_QUERY_BAR_SELECTOR); + + cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1); + cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).contains('test-host1'); + cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1); + cy.get(USER_RISK_PREVIEW_TABLE_ROWS).contains('test1'); + }); + + it('show error panel if API returns error and then try to refetch data', () => { + interceptRiskPreviewError(); + + cy.get(RISK_PREVIEW_ERROR).contains('Preview failed'); - it('risk scores reacts on change in search bar query', () => { - cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); - cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 5); + interceptRiskPreviewSuccess(); - fillLocalSearchBar('host.name: "test-host1"'); - submitLocalSearch(LOCAL_QUERY_BAR_SELECTOR); + previewErrorButtonClick(); - cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1); - cy.get(HOST_RISK_PREVIEW_TABLE_ROWS).contains('test-host1'); - cy.get(USER_RISK_PREVIEW_TABLE_ROWS).should('have.length', 1); - cy.get(USER_RISK_PREVIEW_TABLE_ROWS).contains('test1'); + cy.get(RISK_PREVIEW_ERROR).should('not.exist'); + }); }); - it('show error panel if API returns error and then try to refetch data', () => { - cy.intercept('POST', '/internal/risk_score/preview', { - statusCode: 500, + describe('Risk engine', () => { + it('should init, disable and enable risk engine', () => { + cy.get(RISK_SCORE_STATUS).should('have.text', 'Off'); + + // init + riskEngineStatusChange(); + + cy.get(RISK_SCORE_STATUS).should('have.text', 'On'); + + // disable + riskEngineStatusChange(); + + cy.get(RISK_SCORE_STATUS).should('have.text', 'Off'); + + // enable + riskEngineStatusChange(); + + cy.get(RISK_SCORE_STATUS).should('have.text', 'On'); }); - cy.get(RISK_PREVIEW_ERROR).contains('Preview failed'); + it('should show error panel if API returns error ', () => { + cy.get(RISK_SCORE_STATUS).should('have.text', 'Off'); - cy.intercept('POST', '/internal/risk_score/preview', { - statusCode: 200, - body: { - scores: { host: [], user: [] }, - }, + interceptRiskInitError(); + + // init + riskEngineStatusChange(); + + cy.get(RISK_SCORE_ERROR_PANEL).contains('Sorry, there was an error'); }); - cy.get(RISK_PREVIEW_ERROR_BUTTON).click(); + it('should update if there legacy risk score installed', () => { + installRiskScoreModule(); + visit(ENTITY_ANALYTICS_MANAGEMENT_URL); + + cy.get(RISK_SCORE_STATUS).should('not.exist'); - cy.get(RISK_PREVIEW_ERROR).should('not.exist'); + updateRiskEngine(); + updateRiskEngineConfirm(); + + cy.get(RISK_SCORE_STATUS).should('have.text', 'On'); + + deleteRiskScore({ riskScoreEntity: RiskScoreEntity.host, spaceId: 'default' }); + }); }); - }); -}); + } +); diff --git a/x-pack/plugins/security_solution/cypress/screens/entity_analytics_management.ts b/x-pack/plugins/security_solution/cypress/screens/entity_analytics_management.ts index f4c0fa83db1ea..00bac740fe3c5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/entity_analytics_management.ts +++ b/x-pack/plugins/security_solution/cypress/screens/entity_analytics_management.ts @@ -22,3 +22,15 @@ export const RISK_PREVIEW_ERROR = '[data-test-subj="risk-preview-error"]'; export const RISK_PREVIEW_ERROR_BUTTON = '[data-test-subj="risk-preview-error-button"]'; export const LOCAL_QUERY_BAR_SELECTOR = getDataTestSubjectSelector('risk-score-preview-search-bar'); + +export const RISK_SCORE_ERROR_PANEL = '[data-test-subj="risk-score-error-panel"]'; + +export const RISK_SCORE_UPDATE_CANCEL = '[data-test-subj="risk-score-update-cancel"]'; + +export const RISK_SCORE_UPDATE_CONFIRM = '[data-test-subj="risk-score-update-confirm"]'; + +export const RISK_SCORE_UDATE_BUTTON = '[data-test-subj="risk-score-update-button"]'; + +export const RISK_SCORE_STATUS = '[data-test-subj="risk-score-status"]'; + +export const RISK_SCORE_SWITCH = '[data-test-subj="risk-score-switch"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/risk_engine.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/risk_engine.ts new file mode 100644 index 0000000000000..2564615f2ccda --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/risk_engine.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const deleteConfiguration = () => { + cy.request({ + method: 'GET', + url: `/api/saved_objects/_find?type=risk-engine-configuration`, + failOnStatusCode: false, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + }).then((res) => { + const savedObjectId = res?.body?.saved_objects?.[0]?.id; + if (savedObjectId) { + return cy.request({ + method: 'DELETE', + url: `/api/saved_objects/risk-engine-configuration/${savedObjectId}`, + failOnStatusCode: false, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + }); + } + }); +}; + +export const interceptRiskPreviewError = () => { + cy.intercept('POST', '/internal/risk_score/preview', { + statusCode: 500, + }); +}; + +export const interceptRiskPreviewSuccess = () => { + cy.intercept('POST', '/internal/risk_score/preview', { + statusCode: 200, + body: { + scores: { host: [], user: [] }, + }, + }); +}; + +export const interceptRiskInitError = () => { + cy.intercept('POST', '/internal/risk_score/engine/init', { + statusCode: 500, + }); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/risk_scores/index.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/risk_scores/index.ts index 50caf4353c096..30c5b5fccefd1 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/risk_scores/index.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/risk_scores/index.ts @@ -290,3 +290,16 @@ export const interceptInstallRiskScoreModule = () => { export const waitForInstallRiskScoreModule = () => { cy.wait(['@install'], { requestTimeout: 50000 }); }; + +export const installRiskScoreModule = () => { + cy.request({ + url: RISK_SCORE_URL, + method: 'POST', + body: { + riskScoreEntity: 'host', + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + }) + .its('status') + .should('eql', 200); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/entity_analytics.ts b/x-pack/plugins/security_solution/cypress/tasks/entity_analytics.ts index 156b92df634d0..8bfc94e4f8f9c 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/entity_analytics.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/entity_analytics.ts @@ -12,6 +12,12 @@ import { ANOMALIES_TABLE_NEXT_PAGE_BUTTON, } from '../screens/entity_analytics'; import { ENTITY_ANALYTICS_URL } from '../urls/navigation'; +import { + RISK_SCORE_UPDATE_CONFIRM, + RISK_SCORE_UDATE_BUTTON, + RISK_SCORE_SWITCH, + RISK_PREVIEW_ERROR_BUTTON, +} from '../screens/entity_analytics_management'; import { visit } from './login'; @@ -31,3 +37,20 @@ export const enableJob = () => { export const navigateToNextPage = () => { cy.get(ANOMALIES_TABLE_NEXT_PAGE_BUTTON).click(); }; + +export const riskEngineStatusChange = () => { + cy.get(RISK_SCORE_SWITCH).should('not.have.attr', 'disabled'); + cy.get(RISK_SCORE_SWITCH).click(); +}; + +export const updateRiskEngine = () => { + cy.get(RISK_SCORE_UDATE_BUTTON).click(); +}; + +export const updateRiskEngineConfirm = () => { + cy.get(RISK_SCORE_UPDATE_CONFIRM).click(); +}; + +export const previewErrorButtonClick = () => { + cy.get(RISK_PREVIEW_ERROR_BUTTON).click(); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts index f7af9b79dd0e2..e6c261ab6588b 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts @@ -5,10 +5,22 @@ * 2.0. */ -import { RISK_SCORE_PREVIEW_URL } from '../../../common/constants'; +import { + RISK_ENGINE_STATUS_URL, + RISK_SCORE_PREVIEW_URL, + RISK_ENGINE_ENABLE_URL, + RISK_ENGINE_DISABLE_URL, + RISK_ENGINE_INIT_URL, +} from '../../../common/constants'; import { KibanaServices } from '../../common/lib/kibana'; -import type { CalculateScoresResponse } from '../../../server/lib/risk_engine/types'; +import type { + CalculateScoresResponse, + EnableRiskEngineResponse, + GetRiskEngineStatusResponse, + InitRiskEngineResponse, + DisableRiskEngineResponse, +} from '../../../server/lib/risk_engine/types'; import type { RiskScorePreviewRequestSchema } from '../../../common/risk_engine/risk_score_preview/request_schema'; /** @@ -27,3 +39,44 @@ export const fetchRiskScorePreview = async ({ signal, }); }; + +/** + * Fetches risks engine status + */ +export const fetchRiskEngineStatus = async ({ + signal, +}: { + signal?: AbortSignal; +}): Promise => { + return KibanaServices.get().http.fetch(RISK_ENGINE_STATUS_URL, { + method: 'GET', + signal, + }); +}; + +/** + * Init risk score engine + */ +export const initRiskEngine = async (): Promise => { + return KibanaServices.get().http.fetch(RISK_ENGINE_INIT_URL, { + method: 'POST', + }); +}; + +/** + * Enable risk score engine + */ +export const enableRiskEngine = async (): Promise => { + return KibanaServices.get().http.fetch(RISK_ENGINE_ENABLE_URL, { + method: 'POST', + }); +}; + +/** + * Disable risk score engine + */ +export const disableRiskEngine = async (): Promise => { + return KibanaServices.get().http.fetch(RISK_ENGINE_DISABLE_URL, { + method: 'POST', + }); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_disable_risk_engine_mutation.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_disable_risk_engine_mutation.ts new file mode 100644 index 0000000000000..997e93136339e --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_disable_risk_engine_mutation.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import { disableRiskEngine } from '../api'; +import { useInvalidateRiskEngineStatusQuery } from './use_risk_engine_status'; +import type { + EnableRiskEngineResponse, + EnableDisableRiskEngineErrorResponse, +} from '../../../../server/lib/risk_engine/types'; + +export const DISABLE_RISK_ENGINE_MUTATION_KEY = ['POST', 'DISABLE_RISK_ENGINE']; + +export const useDisableRiskEngineMutation = (options?: UseMutationOptions<{}>) => { + const invalidateRiskEngineStatusQuery = useInvalidateRiskEngineStatusQuery(); + + return useMutation( + () => disableRiskEngine(), + { + ...options, + mutationKey: DISABLE_RISK_ENGINE_MUTATION_KEY, + onSettled: (...args) => { + invalidateRiskEngineStatusQuery(); + + if (options?.onSettled) { + options.onSettled(...args); + } + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_enable_risk_engine_mutation.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_enable_risk_engine_mutation.ts new file mode 100644 index 0000000000000..3875a79399dcc --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_enable_risk_engine_mutation.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import { enableRiskEngine } from '../api'; +import { useInvalidateRiskEngineStatusQuery } from './use_risk_engine_status'; +import type { + EnableRiskEngineResponse, + EnableDisableRiskEngineErrorResponse, +} from '../../../../server/lib/risk_engine/types'; +export const ENABLE_RISK_ENGINE_MUTATION_KEY = ['POST', 'ENABLE_RISK_ENGINE']; + +export const useEnableRiskEngineMutation = (options?: UseMutationOptions<{}>) => { + const invalidateRiskEngineStatusQuery = useInvalidateRiskEngineStatusQuery(); + + return useMutation( + () => enableRiskEngine(), + { + ...options, + mutationKey: ENABLE_RISK_ENGINE_MUTATION_KEY, + onSettled: (...args) => { + invalidateRiskEngineStatusQuery(); + + if (options?.onSettled) { + options.onSettled(...args); + } + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_init_risk_engine_mutation.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_init_risk_engine_mutation.ts new file mode 100644 index 0000000000000..d220885148cac --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_init_risk_engine_mutation.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import { initRiskEngine } from '../api'; +import { useInvalidateRiskEngineStatusQuery } from './use_risk_engine_status'; +import type { + InitRiskEngineResponse, + InitRiskEngineError, +} from '../../../../server/lib/risk_engine/types'; + +export const INIT_RISK_ENGINE_STATUS_KEY = ['POST', 'INIT_RISK_ENGINE']; + +export const useInitRiskEngineMutation = (options?: UseMutationOptions<{}>) => { + const invalidateRiskEngineStatusQuery = useInvalidateRiskEngineStatusQuery(); + + return useMutation(() => initRiskEngine(), { + ...options, + mutationKey: INIT_RISK_ENGINE_STATUS_KEY, + onSettled: (...args) => { + invalidateRiskEngineStatusQuery(); + + if (options?.onSettled) { + options.onSettled(...args); + } + }, + }); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_engine_status.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_engine_status.ts new file mode 100644 index 0000000000000..981fc29d8f702 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_engine_status.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { fetchRiskEngineStatus } from '../api'; +import { RiskEngineStatus } from '../../../../common/risk_engine/types'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +const FETCH_RISK_ENGINE_STATUS = ['GET', 'FETCH_RISK_ENGINE_STATUS']; + +export const useInvalidateRiskEngineStatusQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + queryClient.invalidateQueries(FETCH_RISK_ENGINE_STATUS, { + refetchType: 'active', + }); + }, [queryClient]); +}; + +export const useRiskEngineStatus = () => { + const isRiskEngineEnabled = useIsExperimentalFeatureEnabled('riskScoringRoutesEnabled'); + + return useQuery(FETCH_RISK_ENGINE_STATUS, async ({ signal }) => { + if (!isRiskEngineEnabled) { + return null; + } + const response = await fetchRiskEngineStatus({ signal }); + const isUpdateAvailable = + response?.legacy_risk_engine_status === RiskEngineStatus.ENABLED && + response.risk_engine_status === RiskEngineStatus.NOT_INSTALLED; + return { + isUpdateAvailable, + ...response, + }; + }); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_score_enable_section.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_score_enable_section.tsx index 704c409388609..62361ee3223db 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_score_enable_section.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_score_enable_section.tsx @@ -15,6 +15,18 @@ import { EuiSpacer, EuiSwitch, EuiTitle, + EuiLoadingSpinner, + EuiBadge, + EuiButtonEmpty, + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + EuiCallOut, + EuiAccordion, } from '@elastic/eui'; import { DETECTION_ENTITY_DASHBOARD, @@ -22,6 +34,11 @@ import { RISKY_USERS_DOC_LINK, } from '../../../common/constants'; import * as i18n from '../translations'; +import { useRiskEngineStatus } from '../api/hooks/use_risk_engine_status'; +import { useInitRiskEngineMutation } from '../api/hooks/use_init_risk_engine_mutation'; +import { useEnableRiskEngineMutation } from '../api/hooks/use_enable_risk_engine_mutation'; +import { useDisableRiskEngineMutation } from '../api/hooks/use_disable_risk_engine_mutation'; +import { RiskEngineStatus } from '../../../common/risk_engine/types'; const docsLinks = [ { @@ -40,40 +57,205 @@ const docsLinks = [ const MIN_WIDTH_TO_PREVENT_LABEL_FROM_MOVING = '50px'; +const RiskScoreErrorPanel = ({ errors }: { errors: string[] }) => ( + <> + + +

{i18n.ERROR_PANEL_MESSAGE}

+ + + <> + {errors.map((error) => ( +
+ {error} + +
+ ))} + +
+
+ +); + export const RiskScoreEnableSection = () => { - const [checked, setChecked] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const { data: riskEngineStatus } = useRiskEngineStatus(); + const initRiskEngineMutation = useInitRiskEngineMutation({ + onSettled: () => { + setIsModalVisible(false); + }, + }); + + const enableRiskEngineMutation = useEnableRiskEngineMutation(); + const disableRiskEngineMutation = useDisableRiskEngineMutation(); + + const currentRiskEngineStatus = riskEngineStatus?.risk_engine_status; + + const closeModal = () => setIsModalVisible(false); + const showModal = () => setIsModalVisible(true); + + const isLoading = + initRiskEngineMutation.isLoading || + enableRiskEngineMutation.isLoading || + disableRiskEngineMutation.isLoading; + + const isUpdateAvailable = riskEngineStatus?.isUpdateAvailable; + const btnIsDisabled = !currentRiskEngineStatus || isLoading; + + const onSwitchClick = () => { + if (btnIsDisabled) { + return; + } + if (currentRiskEngineStatus === RiskEngineStatus.NOT_INSTALLED) { + initRiskEngineMutation.mutate(); + } else if (currentRiskEngineStatus === RiskEngineStatus.ENABLED) { + disableRiskEngineMutation.mutate(); + } else if (currentRiskEngineStatus === RiskEngineStatus.DISABLED) { + enableRiskEngineMutation.mutate(); + } + }; + + let modal; + + if (isModalVisible) { + modal = ( + + {initRiskEngineMutation.isLoading ? ( + + + + {i18n.UPDATING_RISK_ENGINE} + + + ) : ( + <> + + {i18n.UPDATE_RISK_ENGINE_MODAL_TITLE} + + + + +

+ {i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_1} + {i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_2} +

+ +

+ {i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_1} + {i18n.UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_2} +

+
+ +
+ + + + {i18n.UPDATE_RISK_ENGINE_MODAL_BUTTON_NO} + + initRiskEngineMutation.mutate()} + fill + > + {i18n.UPDATE_RISK_ENGINE_MODAL_BUTTON_YES} + + + + )} +
+ ); + } + + let initRiskEngineErrors: string[] = []; + + if (initRiskEngineMutation.isError) { + const errorBody = initRiskEngineMutation.error.body.message; + if (errorBody?.full_error?.errors) { + initRiskEngineErrors = errorBody.full_error?.errors; + } else { + initRiskEngineErrors = [errorBody]; + } + } return ( <> <>

{i18n.RISK_SCORE_MODULE_STATUS}

+ {initRiskEngineMutation.isError && } + {disableRiskEngineMutation.isError && ( + + )} + {enableRiskEngineMutation.isError && ( + + )} + + {modal} - - {i18n.ENTITY_RISK_SCORING} + + - - - {checked ? ( - {i18n.RISK_SCORE_MODULE_STATUS_ON} - ) : ( - {i18n.RISK_SCORE_MODULE_STATUS_OFF} - )} - - - setChecked(e.target.checked)} - compressed - aria-describedby={'switchRiskModule'} - /> - + + {i18n.ENTITY_RISK_SCORING} + {isUpdateAvailable && {i18n.UPDATE_AVAILABLE}} + + {isUpdateAvailable && ( + + + {initRiskEngineMutation.isLoading && } + + + {i18n.START_UPDATE} + + + )} + {!isUpdateAvailable && ( + + {isLoading && } + + {currentRiskEngineStatus === RiskEngineStatus.ENABLED ? ( + {i18n.RISK_SCORE_MODULE_STATUS_ON} + ) : ( + {i18n.RISK_SCORE_MODULE_STATUS_OFF} + )} + + + + + + )} + diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_score_update_panel.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_score_update_panel.tsx new file mode 100644 index 0000000000000..13668bc595ad8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_score_update_panel.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCallOut, EuiText, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; +import * as i18n from '../translations'; +import { SecuritySolutionLinkButton } from '../../common/components/links'; +import { SecurityPageName } from '../../../common/constants'; + +export const RiskScoreUpdatePanel = () => { + return ( + + {i18n.UPDATE_PANEL_MESSAGE} + + + + {i18n.UPDATE_PANEL_GO_TO_MANAGE} + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/translations.ts b/x-pack/plugins/security_solution/public/entity_analytics/translations.ts index 24169a0590eb4..5fc6a5893b512 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/translations.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/translations.ts @@ -130,3 +130,117 @@ export const PREVIEW_QUERY_ERROR_TITLE = i18n.translate( defaultMessage: 'Invalid query', } ); + +export const UPDATE_AVAILABLE = i18n.translate('xpack.securitySolution.riskScore.updateAvailable', { + defaultMessage: 'Update available', +}); + +export const START_UPDATE = i18n.translate('xpack.securitySolution.riskScore.startUpdate', { + defaultMessage: 'Start update', +}); + +export const UPDATING_RISK_ENGINE = i18n.translate( + 'xpack.securitySolution.riskScore.updatingRiskEngine', + { + defaultMessage: 'Updating risk engine...', + } +); + +export const UPDATE_RISK_ENGINE_MODAL_TITLE = i18n.translate( + 'xpack.securitySolution.riskScore.updateRiskEngineModa.title', + { + defaultMessage: 'Do you want to update the entity risk engine?', + } +); + +export const UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_1 = i18n.translate( + 'xpack.securitySolution.riskScore.updateRiskEngineModal.existingUserHost_1', + { + defaultMessage: 'Existing user and host risk score transforms will be deleted', + } +); + +export const UPDATE_RISK_ENGINE_MODAL_EXISTING_USER_HOST_2 = i18n.translate( + 'xpack.securitySolution.riskScore.updateRiskEngineModal.existingUserHost_2', + { + defaultMessage: ', as they are no longer required.', + } +); + +export const UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_1 = i18n.translate( + 'xpack.securitySolution.riskScore.updateRiskEngineModal.existingData_1', + { + defaultMessage: 'None of your risk score data will be deleted', + } +); + +export const UPDATE_RISK_ENGINE_MODAL_EXISTING_DATA_2 = i18n.translate( + 'xpack.securitySolution.riskScore.updateRiskEngineModal.existingData_2', + { + defaultMessage: ', you will need to remove any old risk score data manually.', + } +); + +export const UPDATE_RISK_ENGINE_MODAL_BUTTON_NO = i18n.translate( + 'xpack.securitySolution.riskScore.updateRiskEngineModal.buttonNo', + { + defaultMessage: 'No, not yet', + } +); + +export const UPDATE_RISK_ENGINE_MODAL_BUTTON_YES = i18n.translate( + 'xpack.securitySolution.riskScore.updateRiskEngineModal.buttonYes', + { + defaultMessage: 'Yes, update now!', + } +); + +export const ERROR_PANEL_TITLE = i18n.translate( + 'xpack.securitySolution.riskScore.errorPanel.title', + { + defaultMessage: 'Sorry, there was an error', + } +); + +export const ERROR_PANEL_MESSAGE = i18n.translate( + 'xpack.securitySolution.riskScore.errorPanel.message', + { + defaultMessage: 'Something went wrong. Try again later.', + } +); + +export const ERROR_PANEL_ERRORS = i18n.translate( + 'xpack.securitySolution.riskScore.errorPanel.errors', + { + defaultMessage: 'Errors', + } +); + +export const UPDATE_PANEL_TITLE = i18n.translate( + 'xpack.securitySolution.riskScore.updatePanel.title', + { + defaultMessage: 'New entity risk scoring engine available', + } +); + +export const UPDATE_PANEL_MESSAGE = i18n.translate( + 'xpack.securitySolution.riskScore.updatePanel.message', + { + defaultMessage: + 'A new entity risk scoring engine is available. Update now to get the latest features.', + } +); + +export const UPDATE_PANEL_GO_TO_MANAGE = i18n.translate( + 'xpack.securitySolution.riskScore.updatePanel.goToManage', + { + defaultMessage: 'Manage', + } +); + +export const UPDATE_PANEL_GO_TO_DISMISS = i18n.translate( + 'xpack.securitySolution.riskScore.updatePanel.Dismiss', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/plugins/security_solution/public/explore/components/risk_score/risk_details_tab_body/index.tsx b/x-pack/plugins/security_solution/public/explore/components/risk_score/risk_details_tab_body/index.tsx index 185096a7dde97..b71bc7d21587b 100644 --- a/x-pack/plugins/security_solution/public/explore/components/risk_score/risk_details_tab_body/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/components/risk_score/risk_details_tab_body/index.tsx @@ -32,6 +32,8 @@ import type { UsersComponentsQueryProps } from '../../../users/pages/navigation/ import type { HostsComponentsQueryProps } from '../../../hosts/pages/navigation/types'; import { useDashboardHref } from '../../../../common/hooks/use_dashboard_href'; import { RiskScoresNoDataDetected } from '../risk_score_onboarding/risk_score_no_data_detected'; +import { useRiskEngineStatus } from '../../../../entity_analytics/api/hooks/use_risk_engine_status'; +import { RiskScoreUpdatePanel } from '../../../../entity_analytics/components/risk_score_update_panel'; const StyledEuiFlexGroup = styled(EuiFlexGroup)` margin-top: ${({ theme }) => theme.eui.euiSizeL}; @@ -91,6 +93,8 @@ const RiskDetailsTabBodyComponent: React.FC< timerange, }); + const { data: riskScoreEngineStatus } = useRiskEngineStatus(); + const rules = useMemo(() => { const lastRiskItem = data && data.length > 0 ? data[data.length - 1] : null; if (lastRiskItem) { @@ -133,6 +137,10 @@ const RiskDetailsTabBodyComponent: React.FC< return <>{'TODO: Add RiskScore Upsell'}; } + if (riskScoreEngineStatus?.isUpdateAvailable) { + return ; + } + if (status.isDisabled || status.isDeprecated) { return ( { fetch({ @@ -64,19 +68,34 @@ const RiskScoreEnableButtonComponent = ({ ]); return ( - - - + <> + {isRiskEngineEnabled ? ( + + + + ) : ( + + + + )} + ); }; diff --git a/x-pack/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx index 889c42406e54b..5511706a349fe 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx @@ -6,6 +6,7 @@ */ import React, { useEffect, useMemo, useState } from 'react'; +import { EuiPanel } from '@elastic/eui'; import { noop } from 'lodash/fp'; import { EnableRiskScore } from '../../../components/risk_score/enable_risk_score'; import type { HostsComponentsQueryProps } from './types'; @@ -22,6 +23,8 @@ import { import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { EMPTY_SEVERITY_COUNT, RiskScoreEntity } from '../../../../../common/search_strategy'; import { RiskScoresNoDataDetected } from '../../../components/risk_score/risk_score_onboarding/risk_score_no_data_detected'; +import { useRiskEngineStatus } from '../../../../entity_analytics/api/hooks/use_risk_engine_status'; +import { RiskScoreUpdatePanel } from '../../../../entity_analytics/components/risk_score_update_panel'; const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable); @@ -46,6 +49,8 @@ export const HostRiskScoreQueryTabBody = ({ getHostRiskScoreFilterQuerySelector(state, hostsModel.HostsType.page) ); + const { data: riskScoreEngineStatus } = useRiskEngineStatus(); + const pagination = useMemo( () => ({ cursorStart: activePage * limit, @@ -95,14 +100,20 @@ export const HostRiskScoreQueryTabBody = ({ return <>{'TODO: Add RiskScore Upsell'}; } + if (riskScoreEngineStatus?.isUpdateAvailable) { + return ; + } + if (status.isDisabled || status.isDeprecated) { return ( - + + + ); } diff --git a/x-pack/plugins/security_solution/public/explore/users/pages/navigation/user_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/explore/users/pages/navigation/user_risk_score_tab_body.tsx index ed204dd0811fb..0c1aa2abb9cd8 100644 --- a/x-pack/plugins/security_solution/public/explore/users/pages/navigation/user_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/explore/users/pages/navigation/user_risk_score_tab_body.tsx @@ -24,6 +24,8 @@ import { import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { EMPTY_SEVERITY_COUNT, RiskScoreEntity } from '../../../../../common/search_strategy'; import { RiskScoresNoDataDetected } from '../../../components/risk_score/risk_score_onboarding/risk_score_no_data_detected'; +import { useRiskEngineStatus } from '../../../../entity_analytics/api/hooks/use_risk_engine_status'; +import { RiskScoreUpdatePanel } from '../../../../entity_analytics/components/risk_score_update_panel'; const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable); @@ -36,6 +38,7 @@ export const UserRiskScoreQueryTabBody = ({ startDate: from, type, }: UsersComponentsQueryProps) => { + const { data: riskScoreEngineStatus } = useRiskEngineStatus(); const getUserRiskScoreSelector = useMemo(() => usersSelectors.userRiskScoreSelector(), []); const { activePage, limit, sort } = useDeepEqualSelector((state: State) => getUserRiskScoreSelector(state) @@ -97,6 +100,10 @@ export const UserRiskScoreQueryTabBody = ({ return <>{'TODO: Add RiskScore Upsell'}; } + if (riskScoreEngineStatus?.isUpdateAvailable) { + return ; + } + if (status.isDisabled || status.isDeprecated) { return ( { const { deleteQuery, setQuery, from, to } = useGlobalTime(); @@ -125,6 +126,8 @@ const EntityAnalyticsRiskScoresComponent = ({ riskEntity }: { riskEntity: RiskSc includeAlertsCount: true, }); + const { data: riskScoreEngineStatus } = useRiskEngineStatus(); + useQueryInspector({ queryId: entity.tableQueryId, loading: isTableLoading, @@ -149,6 +152,10 @@ const EntityAnalyticsRiskScoresComponent = ({ riskEntity }: { riskEntity: RiskSc isDeprecated: isDeprecated && !isTableLoading, }; + if (riskScoreEngineStatus?.isUpdateAvailable) { + return null; + } + if (status.isDisabled || status.isDeprecated) { return ( { + const { data: riskScoreEngineStatus } = useRiskEngineStatus(); const { indicesExist, loading: isSourcererLoading, indexPattern } = useSourcererDataView(); const { isPlatinumOrTrialLicense, capabilitiesFetched } = useMlCapabilities(); + const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); + const isRiskScoreModuleLicenseAvailable = + isPlatinumOrTrialLicense && hasEntityAnalyticsCapability; return ( <> @@ -48,6 +55,12 @@ const EntityAnalyticsComponent = () => { ) : ( + {riskScoreEngineStatus?.isUpdateAvailable && isRiskScoreModuleLicenseAvailable && ( + + + + )} + diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts index 2512a8c99c0c7..3d38293626e16 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts @@ -10,9 +10,50 @@ import { createOrUpdateIlmPolicy, createOrUpdateIndexTemplate, } from '@kbn/alerting-plugin/server'; -import { loggingSystemMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { + loggingSystemMock, + elasticsearchServiceMock, + savedObjectsClientMock, +} from '@kbn/core/server/mocks'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common/model'; import { RiskEngineDataClient } from './risk_engine_data_client'; import { createDataStream } from './utils/create_datastream'; +import * as savedObjectConfig from './utils/saved_object_configuration'; + +const getSavedObjectConfiguration = (attributes = {}) => ({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'risk-engine-configuration', + id: 'de8ca330-2d26-11ee-bc86-f95bf6192ee6', + namespaces: ['default'], + attributes: { + enabled: false, + ...attributes, + }, + references: [], + managed: false, + updated_at: '2023-07-28T09:52:28.768Z', + created_at: '2023-07-28T09:12:26.083Z', + version: 'WzE4MzIsMV0=', + coreMigrationVersion: '8.8.0', + score: 0, + }, + ], +}); + +const transformsMock = { + count: 1, + transforms: [ + { + id: 'ml_hostriskscore_pivot_transform_default', + dest: { index: '' }, + source: { index: '' }, + }, + ], +}; jest.mock('@kbn/alerting-plugin/server', () => ({ createOrUpdateComponentTemplate: jest.fn(), @@ -28,6 +69,7 @@ describe('RiskEngineDataClient', () => { let riskEngineDataClient: RiskEngineDataClient; let logger: ReturnType; const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const mockSavedObjectClient = savedObjectsClientMock.create(); const totalFieldsLimit = 1000; beforeEach(() => { @@ -290,11 +332,344 @@ describe('RiskEngineDataClient', () => { const error = new Error('There error'); (createOrUpdateIlmPolicy as jest.Mock).mockRejectedValue(error); - await riskEngineDataClient.initializeResources({ namespace: 'default' }); + try { + await riskEngineDataClient.initializeResources({ namespace: 'default' }); + } catch (e) { + expect(logger.error).toHaveBeenCalledWith( + `Error initializing risk engine resources: ${error.message}` + ); + } + }); + }); + + describe('getStatus', () => { + it('should return initial status', async () => { + const status = await riskEngineDataClient.getStatus({ + namespace: 'default', + savedObjectsClient: mockSavedObjectClient, + }); + expect(status).toEqual({ + riskEngineStatus: 'NOT_INSTALLED', + legacyRiskEngineStatus: 'NOT_INSTALLED', + }); + }); + + describe('saved object exists and transforms not', () => { + beforeEach(() => { + mockSavedObjectClient.find.mockResolvedValue(getSavedObjectConfiguration()); + }); + + afterEach(() => { + mockSavedObjectClient.find.mockReset(); + }); + + it('should return status with enabled true', async () => { + mockSavedObjectClient.find.mockResolvedValue( + getSavedObjectConfiguration({ + enabled: true, + }) + ); + + const status = await riskEngineDataClient.getStatus({ + namespace: 'default', + savedObjectsClient: mockSavedObjectClient, + }); + expect(status).toEqual({ + riskEngineStatus: 'ENABLED', + legacyRiskEngineStatus: 'NOT_INSTALLED', + }); + }); + + it('should return status with enabled false', async () => { + mockSavedObjectClient.find.mockResolvedValue(getSavedObjectConfiguration()); + + const status = await riskEngineDataClient.getStatus({ + namespace: 'default', + savedObjectsClient: mockSavedObjectClient, + }); + expect(status).toEqual({ + riskEngineStatus: 'DISABLED', + legacyRiskEngineStatus: 'NOT_INSTALLED', + }); + }); + }); + + describe('legacy transforms', () => { + it('should fetch transforms', async () => { + await riskEngineDataClient.getStatus({ + namespace: 'default', + savedObjectsClient: mockSavedObjectClient, + }); + + expect(esClient.transform.getTransform).toHaveBeenCalledTimes(4); + expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(1, { + transform_id: 'ml_hostriskscore_pivot_transform_default', + }); + expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(2, { + transform_id: 'ml_hostriskscore_latest_transform_default', + }); + expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(3, { + transform_id: 'ml_userriskscore_pivot_transform_default', + }); + expect(esClient.transform.getTransform).toHaveBeenNthCalledWith(4, { + transform_id: 'ml_userriskscore_latest_transform_default', + }); + }); + + it('should return that legacy transform enabled if at least on transform exist', async () => { + esClient.transform.getTransform.mockResolvedValueOnce(transformsMock); + + const status = await riskEngineDataClient.getStatus({ + namespace: 'default', + savedObjectsClient: mockSavedObjectClient, + }); + + expect(status).toEqual({ + riskEngineStatus: 'NOT_INSTALLED', + legacyRiskEngineStatus: 'ENABLED', + }); - expect(logger.error).toHaveBeenCalledWith( - `Error initializing risk engine resources: ${error.message}` + esClient.transform.getTransformStats.mockReset(); + }); + }); + }); + + describe('enableRiskEngine', () => { + afterEach(() => { + mockSavedObjectClient.find.mockReset(); + }); + + it('should return error if saved object not exist', async () => { + mockSavedObjectClient.find.mockResolvedValueOnce({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + + expect.assertions(1); + try { + await riskEngineDataClient.enableRiskEngine({ + savedObjectsClient: mockSavedObjectClient, + user: { username: 'elastic' } as AuthenticatedUser, + }); + } catch (e) { + expect(e.message).toEqual('There no saved object configuration for risk engine'); + } + }); + + it('should update saved object attrubute', async () => { + mockSavedObjectClient.find.mockResolvedValueOnce(getSavedObjectConfiguration()); + + await riskEngineDataClient.enableRiskEngine({ + savedObjectsClient: mockSavedObjectClient, + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(mockSavedObjectClient.update).toHaveBeenCalledWith( + 'risk-engine-configuration', + 'de8ca330-2d26-11ee-bc86-f95bf6192ee6', + { + enabled: true, + }, + { + refresh: 'wait_for', + } ); }); }); + + describe('disableRiskEngine', () => { + afterEach(() => { + mockSavedObjectClient.find.mockReset(); + }); + + it('should return error if saved object not exist', async () => { + mockSavedObjectClient.find.mockResolvedValueOnce({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + + expect.assertions(1); + try { + await riskEngineDataClient.disableRiskEngine({ + savedObjectsClient: mockSavedObjectClient, + user: { username: 'elastic' } as AuthenticatedUser, + }); + } catch (e) { + expect(e.message).toEqual('There no saved object configuration for risk engine'); + } + }); + + it('should update saved object attrubute', async () => { + mockSavedObjectClient.find.mockResolvedValueOnce(getSavedObjectConfiguration()); + + await riskEngineDataClient.disableRiskEngine({ + savedObjectsClient: mockSavedObjectClient, + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(mockSavedObjectClient.update).toHaveBeenCalledWith( + 'risk-engine-configuration', + 'de8ca330-2d26-11ee-bc86-f95bf6192ee6', + { + enabled: false, + }, + { + refresh: 'wait_for', + } + ); + }); + }); + + describe('init', () => { + const initializeResourcesMock = jest.spyOn( + RiskEngineDataClient.prototype, + 'initializeResources' + ); + const enableRiskEngineMock = jest.spyOn(RiskEngineDataClient.prototype, 'enableRiskEngine'); + + const disableLegacyRiskEngineMock = jest.spyOn( + RiskEngineDataClient.prototype, + 'disableLegacyRiskEngine' + ); + beforeEach(() => { + disableLegacyRiskEngineMock.mockImplementation(() => Promise.resolve(true)); + + initializeResourcesMock.mockImplementation(() => { + return Promise.resolve(); + }); + + enableRiskEngineMock.mockImplementation(() => { + return Promise.resolve(getSavedObjectConfiguration().saved_objects[0]); + }); + + jest.spyOn(savedObjectConfig, 'initSavedObjects').mockImplementation(() => { + return Promise.resolve(getSavedObjectConfiguration().saved_objects[0]); + }); + }); + + afterEach(() => { + initializeResourcesMock.mockReset(); + enableRiskEngineMock.mockReset(); + disableLegacyRiskEngineMock.mockReset(); + }); + + it('success', async () => { + const initResult = await riskEngineDataClient.init({ + savedObjectsClient: mockSavedObjectClient, + namespace: 'default', + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(initResult).toEqual({ + errors: [], + legacyRiskEngineDisabled: true, + riskEngineConfigurationCreated: true, + riskEngineEnabled: true, + riskEngineResourcesInstalled: true, + }); + }); + + it('should catch error for disableLegacyRiskEngine, but continue', async () => { + disableLegacyRiskEngineMock.mockImplementation(() => { + throw new Error('Error disableLegacyRiskEngineMock'); + }); + const initResult = await riskEngineDataClient.init({ + savedObjectsClient: mockSavedObjectClient, + namespace: 'default', + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(initResult).toEqual({ + errors: ['Error disableLegacyRiskEngineMock'], + legacyRiskEngineDisabled: false, + riskEngineConfigurationCreated: true, + riskEngineEnabled: true, + riskEngineResourcesInstalled: true, + }); + }); + + it('should catch error for resource init', async () => { + disableLegacyRiskEngineMock.mockImplementationOnce(() => { + throw new Error('Error disableLegacyRiskEngineMock'); + }); + + const initResult = await riskEngineDataClient.init({ + savedObjectsClient: mockSavedObjectClient, + namespace: 'default', + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(initResult).toEqual({ + errors: ['Error disableLegacyRiskEngineMock'], + legacyRiskEngineDisabled: false, + riskEngineConfigurationCreated: true, + riskEngineEnabled: true, + riskEngineResourcesInstalled: true, + }); + }); + + it('should catch error for initializeResources and stop', async () => { + initializeResourcesMock.mockImplementationOnce(() => { + throw new Error('Error initializeResourcesMock'); + }); + + const initResult = await riskEngineDataClient.init({ + savedObjectsClient: mockSavedObjectClient, + namespace: 'default', + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(initResult).toEqual({ + errors: ['Error initializeResourcesMock'], + legacyRiskEngineDisabled: true, + riskEngineConfigurationCreated: false, + riskEngineEnabled: false, + riskEngineResourcesInstalled: false, + }); + }); + + it('should catch error for initSavedObjects and stop', async () => { + jest.spyOn(savedObjectConfig, 'initSavedObjects').mockImplementationOnce(() => { + throw new Error('Error initSavedObjects'); + }); + + const initResult = await riskEngineDataClient.init({ + savedObjectsClient: mockSavedObjectClient, + namespace: 'default', + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(initResult).toEqual({ + errors: ['Error initSavedObjects'], + legacyRiskEngineDisabled: true, + riskEngineConfigurationCreated: false, + riskEngineEnabled: false, + riskEngineResourcesInstalled: true, + }); + }); + + it('should catch error for enableRiskEngineMock and stop', async () => { + enableRiskEngineMock.mockImplementationOnce(() => { + throw new Error('Error enableRiskEngineMock'); + }); + + const initResult = await riskEngineDataClient.init({ + savedObjectsClient: mockSavedObjectClient, + namespace: 'default', + user: { username: 'elastic' } as AuthenticatedUser, + }); + + expect(initResult).toEqual({ + errors: ['Error enableRiskEngineMock'], + legacyRiskEngineDisabled: true, + riskEngineConfigurationCreated: true, + riskEngineEnabled: false, + riskEngineResourcesInstalled: true, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts index 17adae352a164..f338686f3ceac 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts @@ -6,6 +6,7 @@ */ import type { Metadata } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common/model'; import type { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types'; import { createOrUpdateComponentTemplate, @@ -15,6 +16,7 @@ import { import { mappingFromFieldMap } from '@kbn/alerting-plugin/common'; import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; import type { Logger, ElasticsearchClient } from '@kbn/core/server'; + import { riskScoreFieldMap, getIndexPattern, @@ -26,6 +28,20 @@ import { import { createDataStream } from './utils/create_datastream'; import type { RiskEngineDataWriter as Writer } from './risk_engine_data_writer'; import { RiskEngineDataWriter } from './risk_engine_data_writer'; +import type { InitRiskEngineResult } from '../../../common/risk_engine/types'; +import { RiskEngineStatus } from '../../../common/risk_engine/types'; +import { getLegacyTransforms, removeLegacyTransforms } from './utils/risk_engine_transforms'; +import { + updateSavedObjectAttribute, + getConfiguration, + initSavedObjects, +} from './utils/saved_object_configuration'; +import type { UpdateConfigOpts, SavedObjectsClients } from './utils/saved_object_configuration'; + +interface InitOpts extends SavedObjectsClients { + namespace: string; + user: AuthenticatedUser | null | undefined; +} interface InitializeRiskEngineResourcesOpts { namespace?: string; @@ -41,6 +57,49 @@ export class RiskEngineDataClient { private writerCache: Map = new Map(); constructor(private readonly options: RiskEngineDataClientOpts) {} + public async init({ namespace, savedObjectsClient, user }: InitOpts) { + const result: InitRiskEngineResult = { + legacyRiskEngineDisabled: false, + riskEngineResourcesInstalled: false, + riskEngineConfigurationCreated: false, + riskEngineEnabled: false, + errors: [] as string[], + }; + + try { + result.legacyRiskEngineDisabled = await this.disableLegacyRiskEngine({ namespace }); + } catch (e) { + result.legacyRiskEngineDisabled = false; + result.errors.push(e.message); + } + + try { + await this.initializeResources({ namespace }); + result.riskEngineResourcesInstalled = true; + } catch (e) { + result.errors.push(e.message); + return result; + } + + try { + await initSavedObjects({ savedObjectsClient, user }); + result.riskEngineConfigurationCreated = true; + } catch (e) { + result.errors.push(e.message); + return result; + } + + try { + await this.enableRiskEngine({ savedObjectsClient, user }); + result.riskEngineEnabled = true; + } catch (e) { + result.errors.push(e.message); + return result; + } + + return result; + } + public async getWriter({ namespace }: { namespace: string }): Promise { if (this.writerCache.get(namespace)) { return this.writerCache.get(namespace) as Writer; @@ -57,10 +116,85 @@ export class RiskEngineDataClient { index, logger: this.options.logger, }); + this.writerCache.set(namespace, writer); return writer; } + public async getStatus({ + savedObjectsClient, + namespace, + }: SavedObjectsClients & { + namespace: string; + }) { + const riskEngineStatus = await this.getCurrentStatus({ savedObjectsClient }); + const legacyRiskEngineStatus = await this.getLegacyStatus({ namespace }); + return { riskEngineStatus, legacyRiskEngineStatus }; + } + + public async enableRiskEngine({ savedObjectsClient, user }: UpdateConfigOpts) { + // code to run task + + return updateSavedObjectAttribute({ + savedObjectsClient, + user, + attributes: { + enabled: true, + }, + }); + } + + public async disableRiskEngine({ savedObjectsClient, user }: UpdateConfigOpts) { + // code to stop task + + return updateSavedObjectAttribute({ + savedObjectsClient, + user, + attributes: { + enabled: false, + }, + }); + } + + public async disableLegacyRiskEngine({ namespace }: { namespace: string }) { + const legacyRiskEngineStatus = await this.getLegacyStatus({ namespace }); + + if (legacyRiskEngineStatus === RiskEngineStatus.NOT_INSTALLED) { + return true; + } + + const esClient = await this.options.elasticsearchClientPromise; + await removeLegacyTransforms({ + esClient, + namespace, + }); + + const newlegacyRiskEngineStatus = await this.getLegacyStatus({ namespace }); + + return newlegacyRiskEngineStatus === RiskEngineStatus.NOT_INSTALLED; + } + + private async getCurrentStatus({ savedObjectsClient }: SavedObjectsClients) { + const configuration = await getConfiguration({ savedObjectsClient }); + + if (configuration) { + return configuration.enabled ? RiskEngineStatus.ENABLED : RiskEngineStatus.DISABLED; + } + + return RiskEngineStatus.NOT_INSTALLED; + } + + private async getLegacyStatus({ namespace }: { namespace: string }) { + const esClient = await this.options.elasticsearchClientPromise; + const transforms = await getLegacyTransforms({ namespace, esClient }); + + if (transforms.length === 0) { + return RiskEngineStatus.NOT_INSTALLED; + } + + return RiskEngineStatus.ENABLED; + } + public async initializeResources({ namespace = DEFAULT_NAMESPACE_STRING, }: InitializeRiskEngineResourcesOpts) { @@ -139,6 +273,7 @@ export class RiskEngineDataClient { await this.initializeWriter(namespace, indexPatterns.alias); } catch (error) { this.options.logger.error(`Error initializing risk engine resources: ${error.message}`); + throw error; } } } diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/index.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/index.ts index 2d76d490948d2..1c37efc508f05 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/index.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/index.ts @@ -6,3 +6,7 @@ */ export { riskScorePreviewRoute } from './risk_score_preview_route'; +export { riskEngineInitRoute } from './risk_engine_init_route'; +export { riskEngineEnableRoute } from './risk_engine_enable_route'; +export { riskEngineDisableRoute } from './risk_engine_disable_route'; +export { riskEngineStatusRoute } from './risk_engine_status_route'; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_disable_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_disable_route.ts new file mode 100644 index 0000000000000..1df867a59c824 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_disable_route.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { RISK_ENGINE_DISABLE_URL, APP_ID } from '../../../../common/constants'; +import type { SetupPlugins } from '../../../plugin'; +import type { SecuritySolutionPluginRouter } from '../../../types'; + +export const riskEngineDisableRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger, + security: SetupPlugins['security'] +) => { + router.post( + { + path: RISK_ENGINE_DISABLE_URL, + validate: {}, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + + const securitySolution = await context.securitySolution; + const soClient = (await context.core).savedObjects.client; + const riskEngineClient = securitySolution.getRiskEngineDataClient(); + const user = security?.authc.getCurrentUser(request); + + try { + await riskEngineClient.disableRiskEngine({ + savedObjectsClient: soClient, + user, + }); + return response.ok({ body: { success: true } }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: { message: error.message, full_error: JSON.stringify(e) }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_enable_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_enable_route.ts new file mode 100644 index 0000000000000..edc0ae7f0f64c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_enable_route.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { RISK_ENGINE_ENABLE_URL, APP_ID } from '../../../../common/constants'; +import type { SetupPlugins } from '../../../plugin'; +import type { SecuritySolutionPluginRouter } from '../../../types'; + +export const riskEngineEnableRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger, + security: SetupPlugins['security'] +) => { + router.post( + { + path: RISK_ENGINE_ENABLE_URL, + validate: {}, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const securitySolution = await context.securitySolution; + const soClient = (await context.core).savedObjects.client; + const riskEngineClient = securitySolution.getRiskEngineDataClient(); + const user = security?.authc.getCurrentUser(request); + + try { + await riskEngineClient.enableRiskEngine({ + savedObjectsClient: soClient, + user, + }); + return response.ok({ body: { success: true } }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: { message: error.message, full_error: JSON.stringify(e) }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_init_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_init_route.ts new file mode 100644 index 0000000000000..5bc269901c61c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_init_route.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { RISK_ENGINE_INIT_URL, APP_ID } from '../../../../common/constants'; +import type { SetupPlugins } from '../../../plugin'; + +import type { SecuritySolutionPluginRouter } from '../../../types'; + +export const riskEngineInitRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger, + security: SetupPlugins['security'] +) => { + router.post( + { + path: RISK_ENGINE_INIT_URL, + validate: {}, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const securitySolution = await context.securitySolution; + const soClient = (await context.core).savedObjects.client; + const riskEngineClient = securitySolution.getRiskEngineDataClient(); + const spaceId = securitySolution.getSpaceId(); + const user = security?.authc.getCurrentUser(request); + + try { + const initResult = await riskEngineClient.init({ + savedObjectsClient: soClient, + namespace: spaceId, + user, + }); + + const initResultResponse = { + risk_engine_enabled: initResult.riskEngineEnabled, + risk_engine_resources_installed: initResult.riskEngineResourcesInstalled, + risk_engine_configuration_created: initResult.riskEngineConfigurationCreated, + legacy_risk_engine_disabled: initResult.legacyRiskEngineDisabled, + errors: initResult.errors, + }; + + if ( + !initResult.riskEngineEnabled || + !initResult.riskEngineResourcesInstalled || + !initResult.riskEngineConfigurationCreated + ) { + return siemResponse.error({ + statusCode: 400, + body: { + message: initResultResponse.errors.join('\n'), + full_error: initResultResponse, + }, + }); + } + return response.ok({ body: { result: initResultResponse } }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: { message: error.message, full_error: JSON.stringify(e) }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_status_route.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_status_route.ts new file mode 100644 index 0000000000000..a6f59e558ea8d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/routes/risk_engine_status_route.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { RISK_ENGINE_STATUS_URL, APP_ID } from '../../../../common/constants'; + +import type { SecuritySolutionPluginRouter } from '../../../types'; + +export const riskEngineStatusRoute = (router: SecuritySolutionPluginRouter, logger: Logger) => { + router.get( + { + path: RISK_ENGINE_STATUS_URL, + validate: {}, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + + const securitySolution = await context.securitySolution; + const soClient = (await context.core).savedObjects.client; + const riskEngineClient = securitySolution.getRiskEngineDataClient(); + const spaceId = securitySolution.getSpaceId(); + + try { + const result = await riskEngineClient.getStatus({ + savedObjectsClient: soClient, + namespace: spaceId, + }); + return response.ok({ + body: { + risk_engine_status: result.riskEngineStatus, + legacy_risk_engine_status: result.legacyRiskEngineStatus, + }, + }); + } catch (e) { + const error = transformError(e); + + return siemResponse.error({ + statusCode: error.statusCode, + body: { message: error.message, full_error: JSON.stringify(e) }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/saved_object/index.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/saved_object/index.ts new file mode 100644 index 0000000000000..da4681008403e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/saved_object/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './risk_engine_configuration_type'; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/saved_object/risk_engine_configuration_type.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/saved_object/risk_engine_configuration_type.ts new file mode 100644 index 0000000000000..81171fde5e3b4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/saved_object/risk_engine_configuration_type.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SECURITY_SOLUTION_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import type { SavedObjectsType } from '@kbn/core/server'; + +export const riskEngineConfigurationTypeName = 'risk-engine-configuration'; + +export const riskEngineConfigurationTypeMappings: SavedObjectsType['mappings'] = { + dynamic: false, + properties: { + enabled: { + type: 'boolean', + }, + }, +}; + +export const riskEngineConfigurationType: SavedObjectsType = { + name: riskEngineConfigurationTypeName, + indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, + hidden: false, + namespaceType: 'multiple-isolated', + mappings: riskEngineConfigurationTypeMappings, +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/schema/risk_score_apis.yml b/x-pack/plugins/security_solution/server/lib/risk_engine/schema/risk_score_apis.yml index 8cba2d151126a..8e28abb7c47ea 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/schema/risk_score_apis.yml +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/schema/risk_score_apis.yml @@ -44,6 +44,56 @@ paths: $ref: '#/components/schemas/RiskScoresPreviewResponse' '400': description: Invalid request + /engine/status: + get: + summary: Get the status of the Risk Engine + description: Returns the status of both the legacy transform-based risk engine, as well as the new risk engine + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RiskEngineStatusResponse' + + /engine/init: + post: + summary: Initialize the Risk Engine + description: Initializes the Risk Engine by creating the necessary indices and mappings, removing old transforms, and starting the new risk engine + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RiskEngineInitResponse' + /engine/enable: + post: + summary: Enable the Risk Engine + requestBody: + content: + application/json: {} + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RiskEngineEnableResponse' + /engine/disable: + post: + summary: Disable the Risk Engine + requestBody: + content: + application/json: {} + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/RiskEngineDisableResponse' + components: schemas: @@ -153,7 +203,46 @@ components: description: A list of risk scores items: $ref: '#/components/schemas/RiskScore' + RiskEngineStatusResponse: + type: object + properties: + legacy_risk_engine_status: + $ref: '#/components/schemas/RiskEngineStatus' + risk_engine_status: + $ref: '#/components/schemas/RiskEngineStatus' + RiskEngineInitResponse: + type: object + properties: + result: + type: object + properties: + risk_engine_enabled: + type: boolean + risk_engine_resources_installed: + type: boolean + risk_engine_configuration_created: + type: boolean + legacy_risk_engine_disabled: + type: boolean + errors: + type: array + items: + type: string + + + RiskEngineEnableResponse: + type: object + properties: + success: + type: boolean + RiskEngineDisableResponse: + type: object + properties: + success: + type: boolean + + AfterKeys: type: object properties: @@ -326,3 +415,22 @@ components: - type: 'global_identifier' host: 0.5 user: 0.1 + RiskEngineStatus: + type: string + enum: + - 'NOT_INSTALLED' + - 'DISABLED' + - 'ENABLED' + RiskEngineInitStep: + type: object + required: + - type + - success + properties: + type: + type: string + success: + type: boolean + error: + type: string + \ No newline at end of file diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/types.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/types.ts index a01ba5a9c24e0..fff4f7c88ff7b 100644 --- a/x-pack/plugins/security_solution/server/lib/risk_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/types.ts @@ -14,6 +14,7 @@ import type { RiskCategories, RiskWeights, } from '../../../common/risk_engine'; +import type { RiskEngineStatus } from '../../../common/risk_engine/types'; export interface CalculateScoresParams { afterKeys: AfterKeys; @@ -57,6 +58,49 @@ export interface CalculateScoresResponse { }; } +export interface GetRiskEngineStatusResponse { + legacy_risk_engine_status: RiskEngineStatus; + risk_engine_status: RiskEngineStatus; +} + +interface InitRiskEngineResultResponse { + risk_engine_enabled: boolean; + risk_engine_resources_installed: boolean; + risk_engine_configuration_created: boolean; + legacy_risk_engine_disabled: boolean; + errors: string[]; +} + +export interface InitRiskEngineResponse { + result: InitRiskEngineResultResponse; +} + +export interface InitRiskEngineError { + body: { + message: { + message: string; + full_error: InitRiskEngineResultResponse | undefined; + } & string; + }; +} + +export interface EnableDisableRiskEngineErrorResponse { + body: { + message: { + message: string; + full_error: string; + }; + }; +} + +export interface EnableRiskEngineResponse { + success: boolean; +} + +export interface DisableRiskEngineResponse { + success: boolean; +} + export interface SimpleRiskInput { id: string; index: string; @@ -117,3 +161,7 @@ export interface RiskScoreBucket { }; inputs: SearchResponse; } + +export interface RiskEngineConfiguration { + enabled: boolean; +} diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/risk_engine_transforms.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/risk_engine_transforms.ts new file mode 100644 index 0000000000000..e79b93a5267b7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/risk_engine_transforms.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core/server'; +import type { + TransformGetTransformResponse, + TransformGetTransformTransformSummary, +} from '@elastic/elasticsearch/lib/api/types'; +import { RiskScoreEntity } from '../../../../common/search_strategy'; +import { + getRiskScorePivotTransformId, + getRiskScoreLatestTransformId, +} from '../../../../common/utils/risk_score_modules'; + +export const getLegacyTransforms = async ({ + namespace, + esClient, +}: { + namespace: string; + esClient: ElasticsearchClient; +}) => { + const getTransformStatsRequests: Array> = []; + [RiskScoreEntity.host, RiskScoreEntity.user].forEach((entity) => { + getTransformStatsRequests.push( + esClient.transform.getTransform({ + transform_id: getRiskScorePivotTransformId(entity, namespace), + }) + ); + getTransformStatsRequests.push( + esClient.transform.getTransform({ + transform_id: getRiskScoreLatestTransformId(entity, namespace), + }) + ); + }); + + const results = await Promise.allSettled(getTransformStatsRequests); + + const transforms = results.reduce((acc, result) => { + if (result.status === 'fulfilled' && result.value?.transforms?.length > 0) { + acc.push(...result.value.transforms); + } + return acc; + }, [] as TransformGetTransformTransformSummary[]); + + return transforms; +}; + +export const removeLegacyTransforms = async ({ + namespace, + esClient, +}: { + namespace: string; + esClient: ElasticsearchClient; +}): Promise => { + const transforms = await getLegacyTransforms({ namespace, esClient }); + + const stopTransformRequests = transforms.map((t) => + esClient.transform.deleteTransform({ + transform_id: t.id, + force: true, + }) + ); + + await Promise.allSettled(stopTransformRequests); +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/saved_object_configuration.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/saved_object_configuration.ts new file mode 100644 index 0000000000000..13b3d54496fa8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/saved_object_configuration.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common/model'; + +import type { RiskEngineConfiguration } from '../types'; +import { riskEngineConfigurationTypeName } from '../saved_object'; + +export interface SavedObjectsClients { + savedObjectsClient: SavedObjectsClientContract; +} + +export interface UpdateConfigOpts extends SavedObjectsClients { + user: AuthenticatedUser | null | undefined; +} + +const getConfigurationSavedObject = async ({ + savedObjectsClient, +}: SavedObjectsClients): Promise | undefined> => { + const savedObjectsResponse = await savedObjectsClient.find({ + type: riskEngineConfigurationTypeName, + }); + return savedObjectsResponse.saved_objects?.[0]; +}; + +export const updateSavedObjectAttribute = async ({ + savedObjectsClient, + attributes, + user, +}: UpdateConfigOpts & { + attributes: { + enabled: boolean; + }; +}) => { + const savedObjectConfiguration = await getConfigurationSavedObject({ + savedObjectsClient, + }); + + if (!savedObjectConfiguration) { + throw new Error('There no saved object configuration for risk engine'); + } + + const result = await savedObjectsClient.update( + riskEngineConfigurationTypeName, + savedObjectConfiguration.id, + { + ...attributes, + }, + { + refresh: 'wait_for', + } + ); + + return result; +}; + +export const initSavedObjects = async ({ savedObjectsClient, user }: UpdateConfigOpts) => { + const configuration = await getConfigurationSavedObject({ savedObjectsClient }); + if (configuration) { + return configuration; + } + const result = await savedObjectsClient.create(riskEngineConfigurationTypeName, { + enabled: false, + }); + return result; +}; + +export const getConfiguration = async ({ + savedObjectsClient, +}: SavedObjectsClients): Promise => { + try { + const savedObjectConfiguration = await getConfigurationSavedObject({ + savedObjectsClient, + }); + const configuration = savedObjectConfiguration?.attributes; + + if (configuration) { + return configuration; + } + + return null; + } catch (e) { + return null; + } +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 278aecdf8ce68..e64a5808eb3dd 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -170,10 +170,6 @@ export class Plugin implements ISecuritySolutionPlugin { .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), }); - if (experimentalFeatures.riskScoringPersistence) { - this.riskEngineDataClient.initializeResources({}); - } - const requestContextFactory = new RequestContextFactory({ config, logger, diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 63bb97a1cadcc..cd88c9ffaca1b 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -74,7 +74,13 @@ import { registerManageExceptionsRoutes } from '../lib/exceptions/api/register_r import { registerDashboardsRoutes } from '../lib/dashboards/routes'; import { registerTagsRoutes } from '../lib/tags/routes'; import { setAlertTagsRoute } from '../lib/detection_engine/routes/signals/set_alert_tags_route'; -import { riskScorePreviewRoute } from '../lib/risk_engine/routes'; +import { + riskScorePreviewRoute, + riskEngineDisableRoute, + riskEngineInitRoute, + riskEngineEnableRoute, + riskEngineStatusRoute, +} from '../lib/risk_engine/routes'; import { riskScoreCalculationRoute } from '../lib/risk_engine/routes/risk_score_calculation_route'; export const initRoutes = ( @@ -177,5 +183,9 @@ export const initRoutes = ( if (config.experimentalFeatures.riskScoringRoutesEnabled) { riskScorePreviewRoute(router, logger); riskScoreCalculationRoute(router, logger); + riskEngineInitRoute(router, logger, security); + riskEngineEnableRoute(router, logger, security); + riskEngineStatusRoute(router, logger); + riskEngineDisableRoute(router, logger, security); } }; diff --git a/x-pack/plugins/security_solution/server/saved_objects.ts b/x-pack/plugins/security_solution/server/saved_objects.ts index bd6c21a4d489a..096b46528e76f 100644 --- a/x-pack/plugins/security_solution/server/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/saved_objects.ts @@ -13,6 +13,7 @@ import { legacyType as legacyRuleActionsType } from './lib/detection_engine/rule import { prebuiltRuleAssetType } from './lib/detection_engine/prebuilt_rules'; import { type as signalsMigrationType } from './lib/detection_engine/migrations/saved_objects'; import { manifestType } from './endpoint/lib/artifacts/saved_object_mappings'; +import { riskEngineConfigurationType } from './lib/risk_engine/saved_object'; const types = [ noteType, @@ -22,6 +23,7 @@ const types = [ timelineType, manifestType, signalsMigrationType, + riskEngineConfigurationType, ]; export const savedObjectTypes = types.map((type) => type.name); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts index 108f179b5686b..114206c6f851b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts @@ -37,7 +37,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./throttle')); loadTestFile(require.resolve('./ignore_fields')); loadTestFile(require.resolve('./migrations')); - loadTestFile(require.resolve('./risk_engine/risk_engine_install_resources')); + loadTestFile(require.resolve('./risk_engine/risk_engine_status')); loadTestFile(require.resolve('./risk_engine/risk_score_preview')); loadTestFile(require.resolve('./risk_engine/risk_score_calculation')); loadTestFile(require.resolve('./set_alert_tags')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_engine_install_resources.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_engine_install_resources.ts deleted file mode 100644 index a35c3a49b7e99..0000000000000 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_engine_install_resources.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext) => { - const es = getService('es'); - - describe('Risk Engine - Install Resources', () => { - it('should install resources on startup', async () => { - const ilmPolicyName = '.risk-score-ilm-policy'; - const componentTemplateName = '.risk-score-mappings'; - const indexTemplateName = '.risk-score.risk-score-default-index-template'; - const indexName = 'risk-score.risk-score-default'; - - const ilmPolicy = await es.ilm.getLifecycle({ - name: ilmPolicyName, - }); - - expect(ilmPolicy[ilmPolicyName].policy).to.eql({ - _meta: { - managed: true, - }, - phases: { - hot: { - min_age: '0ms', - actions: { - rollover: { - max_age: '30d', - max_primary_shard_size: '50gb', - }, - }, - }, - }, - }); - - const { component_templates: componentTemplates1 } = await es.cluster.getComponentTemplate({ - name: componentTemplateName, - }); - - expect(componentTemplates1.length).to.eql(1); - const componentTemplate = componentTemplates1[0]; - - expect(componentTemplate.name).to.eql(componentTemplateName); - expect(componentTemplate.component_template.template.mappings).to.eql({ - dynamic: 'strict', - properties: { - '@timestamp': { - type: 'date', - }, - host: { - properties: { - name: { - type: 'keyword', - }, - risk: { - properties: { - calculated_level: { - type: 'keyword', - }, - calculated_score: { - type: 'float', - }, - calculated_score_norm: { - type: 'float', - }, - category_1_score: { - type: 'float', - }, - id_field: { - type: 'keyword', - }, - id_value: { - type: 'keyword', - }, - notes: { - type: 'keyword', - }, - inputs: { - properties: { - id: { - type: 'keyword', - }, - index: { - type: 'keyword', - }, - category: { - type: 'keyword', - }, - description: { - type: 'keyword', - }, - risk_score: { - type: 'float', - }, - timestamp: { - type: 'date', - }, - }, - type: 'object', - }, - }, - type: 'object', - }, - }, - }, - user: { - properties: { - name: { - type: 'keyword', - }, - risk: { - properties: { - calculated_level: { - type: 'keyword', - }, - calculated_score: { - type: 'float', - }, - calculated_score_norm: { - type: 'float', - }, - category_1_score: { - type: 'float', - }, - id_field: { - type: 'keyword', - }, - id_value: { - type: 'keyword', - }, - notes: { - type: 'keyword', - }, - inputs: { - properties: { - id: { - type: 'keyword', - }, - index: { - type: 'keyword', - }, - category: { - type: 'keyword', - }, - description: { - type: 'keyword', - }, - risk_score: { - type: 'float', - }, - timestamp: { - type: 'date', - }, - }, - type: 'object', - }, - }, - type: 'object', - }, - }, - }, - }, - }); - - const { index_templates: indexTemplates } = await es.indices.getIndexTemplate({ - name: indexTemplateName, - }); - expect(indexTemplates.length).to.eql(1); - const indexTemplate = indexTemplates[0]; - expect(indexTemplate.name).to.eql(indexTemplateName); - expect(indexTemplate.index_template.index_patterns).to.eql(['risk-score.risk-score-default']); - expect(indexTemplate.index_template.composed_of).to.eql(['.risk-score-mappings']); - expect(indexTemplate.index_template.template!.mappings?.dynamic).to.eql(false); - expect(indexTemplate.index_template.template!.mappings?._meta?.managed).to.eql(true); - expect(indexTemplate.index_template.template!.mappings?._meta?.namespace).to.eql('default'); - expect(indexTemplate.index_template.template!.mappings?._meta?.kibana?.version).to.be.a( - 'string' - ); - expect(indexTemplate.index_template.template!.settings).to.eql({ - index: { - lifecycle: { - name: '.risk-score-ilm-policy', - }, - mapping: { - total_fields: { - limit: '1000', - }, - }, - hidden: 'true', - auto_expand_replicas: '0-1', - }, - }); - - const dsResponse = await es.indices.get({ - index: indexName, - }); - - const dataStream = Object.values(dsResponse).find((ds) => ds.data_stream === indexName); - - expect(dataStream?.mappings?._meta?.managed).to.eql(true); - expect(dataStream?.mappings?._meta?.namespace).to.eql('default'); - expect(dataStream?.mappings?._meta?.kibana?.version).to.be.a('string'); - expect(dataStream?.mappings?.dynamic).to.eql('false'); - - expect(dataStream?.settings?.index?.lifecycle).to.eql({ - name: '.risk-score-ilm-policy', - }); - - expect(dataStream?.settings?.index?.mapping).to.eql({ - total_fields: { - limit: '1000', - }, - }); - - expect(dataStream?.settings?.index?.hidden).to.eql('true'); - expect(dataStream?.settings?.index?.number_of_shards).to.eql(1); - expect(dataStream?.settings?.index?.auto_expand_replicas).to.eql('0-1'); - }); - }); -}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_engine_status.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_engine_status.ts new file mode 100644 index 0000000000000..dae7bd2cd3ab6 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/risk_engine_status.ts @@ -0,0 +1,389 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { + RISK_ENGINE_INIT_URL, + RISK_ENGINE_DISABLE_URL, + RISK_ENGINE_ENABLE_URL, + RISK_ENGINE_STATUS_URL, +} from '@kbn/security-solution-plugin/common/constants'; +import { riskEngineConfigurationTypeName } from '@kbn/security-solution-plugin/server/lib/risk_engine/saved_object'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + cleanRiskEngineConfig, + legacyTransformIds, + createTransforms, + clearLegacyTransforms, +} from './utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const es = getService('es'); + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('Risk Engine', () => { + afterEach(async () => { + await cleanRiskEngineConfig({ + kibanaServer, + }); + await clearLegacyTransforms({ + es, + }); + }); + + const initRiskEngine = async () => + await supertest.post(RISK_ENGINE_INIT_URL).set('kbn-xsrf', 'true').send().expect(200); + + const getRiskEngineStatus = async () => + await supertest.get(RISK_ENGINE_STATUS_URL).set('kbn-xsrf', 'true').send().expect(200); + + const enableRiskEngine = async () => + await supertest.post(RISK_ENGINE_ENABLE_URL).set('kbn-xsrf', 'true').send().expect(200); + + const disableRiskEngine = async () => + await supertest.post(RISK_ENGINE_DISABLE_URL).set('kbn-xsrf', 'true').send().expect(200); + + describe('init api', () => { + it('should return response with success status', async () => { + const response = await initRiskEngine(); + expect(response.body).to.eql({ + result: { + errors: [], + legacy_risk_engine_disabled: true, + risk_engine_configuration_created: true, + risk_engine_enabled: true, + risk_engine_resources_installed: true, + }, + }); + }); + + it('should install resources on init call', async () => { + const ilmPolicyName = '.risk-score-ilm-policy'; + const componentTemplateName = '.risk-score-mappings'; + const indexTemplateName = '.risk-score.risk-score-default-index-template'; + const indexName = 'risk-score.risk-score-default'; + + await initRiskEngine(); + + const ilmPolicy = await es.ilm.getLifecycle({ + name: ilmPolicyName, + }); + + expect(ilmPolicy[ilmPolicyName].policy).to.eql({ + _meta: { + managed: true, + }, + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + }); + + const { component_templates: componentTemplates1 } = await es.cluster.getComponentTemplate({ + name: componentTemplateName, + }); + + expect(componentTemplates1.length).to.eql(1); + const componentTemplate = componentTemplates1[0]; + + expect(componentTemplate.name).to.eql(componentTemplateName); + expect(componentTemplate.component_template.template.mappings).to.eql({ + dynamic: 'strict', + properties: { + '@timestamp': { + type: 'date', + }, + host: { + properties: { + name: { + type: 'keyword', + }, + risk: { + properties: { + calculated_level: { + type: 'keyword', + }, + calculated_score: { + type: 'float', + }, + calculated_score_norm: { + type: 'float', + }, + category_1_score: { + type: 'float', + }, + id_field: { + type: 'keyword', + }, + id_value: { + type: 'keyword', + }, + notes: { + type: 'keyword', + }, + inputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + category: { + type: 'keyword', + }, + description: { + type: 'keyword', + }, + risk_score: { + type: 'float', + }, + timestamp: { + type: 'date', + }, + }, + type: 'object', + }, + }, + type: 'object', + }, + }, + }, + user: { + properties: { + name: { + type: 'keyword', + }, + risk: { + properties: { + calculated_level: { + type: 'keyword', + }, + calculated_score: { + type: 'float', + }, + calculated_score_norm: { + type: 'float', + }, + category_1_score: { + type: 'float', + }, + id_field: { + type: 'keyword', + }, + id_value: { + type: 'keyword', + }, + notes: { + type: 'keyword', + }, + inputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + category: { + type: 'keyword', + }, + description: { + type: 'keyword', + }, + risk_score: { + type: 'float', + }, + timestamp: { + type: 'date', + }, + }, + type: 'object', + }, + }, + type: 'object', + }, + }, + }, + }, + }); + + const { index_templates: indexTemplates } = await es.indices.getIndexTemplate({ + name: indexTemplateName, + }); + expect(indexTemplates.length).to.eql(1); + const indexTemplate = indexTemplates[0]; + expect(indexTemplate.name).to.eql(indexTemplateName); + expect(indexTemplate.index_template.index_patterns).to.eql([ + 'risk-score.risk-score-default', + ]); + expect(indexTemplate.index_template.composed_of).to.eql(['.risk-score-mappings']); + expect(indexTemplate.index_template.template!.mappings?.dynamic).to.eql(false); + expect(indexTemplate.index_template.template!.mappings?._meta?.managed).to.eql(true); + expect(indexTemplate.index_template.template!.mappings?._meta?.namespace).to.eql('default'); + expect(indexTemplate.index_template.template!.mappings?._meta?.kibana?.version).to.be.a( + 'string' + ); + expect(indexTemplate.index_template.template!.settings).to.eql({ + index: { + lifecycle: { + name: '.risk-score-ilm-policy', + }, + mapping: { + total_fields: { + limit: '1000', + }, + }, + hidden: 'true', + auto_expand_replicas: '0-1', + }, + }); + + const dsResponse = await es.indices.get({ + index: indexName, + }); + + const dataStream = Object.values(dsResponse).find((ds) => ds.data_stream === indexName); + + expect(dataStream?.mappings?._meta?.managed).to.eql(true); + expect(dataStream?.mappings?._meta?.namespace).to.eql('default'); + expect(dataStream?.mappings?._meta?.kibana?.version).to.be.a('string'); + expect(dataStream?.mappings?.dynamic).to.eql('false'); + + expect(dataStream?.settings?.index?.lifecycle).to.eql({ + name: '.risk-score-ilm-policy', + }); + + expect(dataStream?.settings?.index?.mapping).to.eql({ + total_fields: { + limit: '1000', + }, + }); + + expect(dataStream?.settings?.index?.hidden).to.eql('true'); + expect(dataStream?.settings?.index?.number_of_shards).to.eql(1); + expect(dataStream?.settings?.index?.auto_expand_replicas).to.eql('0-1'); + }); + + it('should create configuration saved object', async () => { + await initRiskEngine(); + const response = await kibanaServer.savedObjects.find({ + type: riskEngineConfigurationTypeName, + }); + + expect(response?.saved_objects?.[0]?.attributes).to.eql({ + enabled: true, + }); + }); + + it('should create configuration saved object only once', async () => { + await initRiskEngine(); + const firstResponse = await kibanaServer.savedObjects.find({ + type: riskEngineConfigurationTypeName, + }); + + await initRiskEngine(); + const secondResponse = await kibanaServer.savedObjects.find({ + type: riskEngineConfigurationTypeName, + }); + + expect(secondResponse?.saved_objects?.length).to.eql(1); + expect(secondResponse?.saved_objects?.[0]?.id).to.eql( + firstResponse?.saved_objects?.[0]?.id + ); + }); + + it('should remove legacy risk score transform if it exists', async () => { + await createTransforms({ es }); + + for (const transformId of legacyTransformIds) { + const tr = await es.transform.getTransform({ + transform_id: transformId, + }); + + expect(tr?.transforms?.[0]?.id).to.eql(transformId); + } + + await initRiskEngine(); + + for (const transformId of legacyTransformIds) { + try { + await es.transform.getTransform({ + transform_id: transformId, + }); + } catch (err) { + expect(err).to.not.be(undefined); + } + } + }); + }); + + describe('status api', () => { + it('should disable / enable risk engige', async () => { + const status1 = await getRiskEngineStatus(); + + expect(status1.body).to.eql({ + risk_engine_status: 'NOT_INSTALLED', + legacy_risk_engine_status: 'NOT_INSTALLED', + }); + + await initRiskEngine(); + + const status2 = await getRiskEngineStatus(); + + expect(status2.body).to.eql({ + risk_engine_status: 'ENABLED', + legacy_risk_engine_status: 'NOT_INSTALLED', + }); + + await disableRiskEngine(); + const status3 = await getRiskEngineStatus(); + + expect(status3.body).to.eql({ + risk_engine_status: 'DISABLED', + legacy_risk_engine_status: 'NOT_INSTALLED', + }); + + await enableRiskEngine(); + const status4 = await getRiskEngineStatus(); + + expect(status4.body).to.eql({ + risk_engine_status: 'ENABLED', + legacy_risk_engine_status: 'NOT_INSTALLED', + }); + }); + + it('should return status of legacy risk engine', async () => { + await createTransforms({ es }); + const status1 = await getRiskEngineStatus(); + + expect(status1.body).to.eql({ + risk_engine_status: 'NOT_INSTALLED', + legacy_risk_engine_status: 'ENABLED', + }); + + await initRiskEngine(); + + const status2 = await getRiskEngineStatus(); + + expect(status2.body).to.eql({ + risk_engine_status: 'ENABLED', + legacy_risk_engine_status: 'NOT_INSTALLED', + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts index 418e0533bac62..e03aa1f843fe4 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine/utils.ts @@ -13,7 +13,8 @@ import type { EcsRiskScore, RiskScore, } from '@kbn/security-solution-plugin/server/lib/risk_engine/types'; - +import { riskEngineConfigurationTypeName } from '@kbn/security-solution-plugin/server/lib/risk_engine/saved_object'; +import type { KbnClient } from '@kbn/test'; import { createRule, waitForSignalsToBePresent, @@ -136,3 +137,78 @@ export const waitForRiskScoresToBePresent = async ( log ); }; + +export const getRiskEngineConfigSO = async ({ kibanaServer }: { kibanaServer: KbnClient }) => { + const soResponse = await kibanaServer.savedObjects.find({ + type: riskEngineConfigurationTypeName, + }); + + return soResponse?.saved_objects?.[0]; +}; + +export const cleanRiskEngineConfig = async ({ + kibanaServer, +}: { + kibanaServer: KbnClient; +}): Promise => { + const so = await getRiskEngineConfigSO({ kibanaServer }); + if (so) { + await kibanaServer.savedObjects.delete({ + type: riskEngineConfigurationTypeName, + id: so.id, + }); + } +}; + +export const legacyTransformIds = [ + 'ml_hostriskscore_pivot_transform_default', + 'ml_hostriskscore_latest_transform_default', + 'ml_userriskscore_pivot_transform_default', + 'ml_userriskscore_latest_transform_default', +]; + +export const clearLegacyTransforms = async ({ es }: { es: Client }): Promise => { + const transforms = legacyTransformIds.map((transform) => + es.transform.deleteTransform({ + transform_id: transform, + }) + ); + try { + await Promise.all(transforms); + } catch (e) { + // + } +}; + +export const createTransforms = async ({ es }: { es: Client }): Promise => { + const transforms = legacyTransformIds.map((transform) => + es.transform.putTransform({ + transform_id: transform, + source: { + index: ['.alerts-security.alerts-default'], + }, + dest: { + index: 'ml_host_risk_score_default', + }, + pivot: { + group_by: { + 'host.name': { + terms: { + field: 'host.name', + }, + }, + }, + aggregations: { + '@timestamp': { + max: { + field: '@timestamp', + }, + }, + }, + }, + settings: {}, + }) + ); + + await Promise.all(transforms); +}; diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index dd9844abe03e3..c50a5403a166c 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -49,7 +49,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'alertDetailsPageEnabled', 'chartEmbeddablesEnabled', - 'riskScoringRoutesEnabled', ])}`, // mock cloud to enable the guided onboarding tour in e2e tests '--xpack.cloud.id=test',