diff --git a/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx b/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx index 76336b10a300a..694f82bf9c079 100644 --- a/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx +++ b/packages/shared-ux/chrome/navigation/__jest__/build_nav_tree.test.tsx @@ -129,11 +129,7 @@ describe('builds navigation tree', () => { ]); const navTree: NavigationTreeDefinitionUI = { - body: [ - { - type: 'recentlyAccessed', - }, - ], + body: [{ type: 'recentlyAccessed' }], }; const { findByTestId } = renderNavigation({ @@ -146,4 +142,30 @@ describe('builds navigation tree', () => { 'RecentThis is an exampleAnother example' ); }); + + test('should limit the number of recently accessed items to 5', async () => { + const recentlyAccessed$ = of([ + { label: 'Item1', link: '/app/foo/1', id: '1' }, + { label: 'Item2', link: '/app/foo/2', id: '2' }, + { label: 'Item3', link: '/app/foo/3', id: '3' }, + { label: 'Item4', link: '/app/foo/4', id: '4' }, + { label: 'Item5', link: '/app/foo/5', id: '5' }, + { label: 'Item6', link: '/app/foo/6', id: '6' }, + { label: 'Item7', link: '/app/foo/7', id: '7' }, + ]); + + const navTree: NavigationTreeDefinitionUI = { + body: [{ type: 'recentlyAccessed' }], + }; + + const { queryAllByTestId } = renderNavigation({ + navTreeDef: of(navTree), + services: { recentlyAccessed$ }, + }); + + const items = await queryAllByTestId(/nav-recentlyAccessed-item/); + expect(items).toHaveLength(5); + const itemsText = items.map((item) => item.textContent); + expect(itemsText).toEqual(['Item1', 'Item2', 'Item3', 'Item4', 'Item5']); + }); }); diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx index 2d39d704ce579..8ceb84fde5de3 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/navigation_section_ui.tsx @@ -318,15 +318,9 @@ const nodeToEuiCollapsibleNavProps = ( return { items, isVisible }; }; -// Temporary solution to prevent showing the outline when the page load when the -// accordion is auto-expanded if one of its children is active -// Once https://github.com/elastic/eui/pull/7314 is released in Kibana we can -// safely remove this CSS class. const className = css` - .euiAccordion__childWrapper, - .euiAccordion__children, - .euiCollapsibleNavAccordion__children { - outline: none; + .euiAccordion__childWrapper { + transition: none; // Remove the transition as it does not play well with dynamic links added to the accordion } `; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx index 0a3af6522a6e6..e4eee48ce88ff 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/components/recently_accessed.tsx @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import React, { FC } from 'react'; -import { EuiCollapsibleNavItem } from '@elastic/eui'; +import React, { FC, useMemo } from 'react'; +import { EuiCollapsibleNavItem, type EuiCollapsibleNavItemProps } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import type { ChromeRecentlyAccessedHistoryItem } from '@kbn/core-chrome-browser'; import type { Observable } from 'rxjs'; @@ -16,6 +16,8 @@ import { useNavigation as useServices } from '../../services'; import { getI18nStrings } from '../i18n_strings'; +const MAX_RECENTLY_ACCESS_ITEMS = 5; + export interface Props { /** * Optional observable for recently accessed items. If not provided, the @@ -31,30 +33,35 @@ export interface Props { export const RecentlyAccessed: FC = ({ recentlyAccessed$: recentlyAccessedProp$, - defaultIsCollapsed = false, + defaultIsCollapsed = true, }) => { const strings = getI18nStrings(); const { recentlyAccessed$, basePath, navigateToUrl } = useServices(); const recentlyAccessed = useObservable(recentlyAccessedProp$ ?? recentlyAccessed$, []); - if (recentlyAccessed.length === 0) { - return null; - } + const navItems = useMemo( + () => + recentlyAccessed.slice(0, MAX_RECENTLY_ACCESS_ITEMS).map((recent) => { + const { id, label, link } = recent; + const href = basePath.prepend(link); - const navItems = recentlyAccessed.map((recent) => { - const { id, label, link } = recent; - const href = basePath.prepend(link); + return { + id, + title: label, + href, + 'data-test-subj': `nav-recentlyAccessed-item nav-recentlyAccessed-item-${id}`, + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + navigateToUrl(href); + }, + }; + }), + [basePath, navigateToUrl, recentlyAccessed] + ); - return { - id, - title: label, - href, - onClick: (e: React.MouseEvent) => { - e.preventDefault(); - navigateToUrl(href); - }, - }; - }); + if (navItems.length === 0) { + return null; + } return ( { expect(fieldCandidates).toEqual([ '_metadata.elastic_apm_trace_id', + '_metadata.elastic_apm_transaction_id', + '_metadata.message_template', '_metadata.metadata_event_dataset', '_metadata.user_id', + 'agent.ephemeral_id', + 'agent.hostname', + 'agent.id', + 'agent.name', + 'agent.type', 'agent.version', + 'client.geo.city_name', + 'client.geo.continent_name', + 'client.geo.country_iso_code', + 'client.geo.country_name', + 'client.geo.region_iso_code', + 'client.geo.region_name', 'client.ip', 'cloud.account.id', + 'cloud.availability_zone', + 'cloud.instance.id', 'cloud.instance.name', + 'cloud.machine.type', + 'cloud.project.id', + 'cloud.provider', + 'cloud.service.name', + 'container.id', + 'container.image.name', + 'container.labels.annotation_io_kubernetes_container_hash', 'container.labels.annotation_io_kubernetes_container_restartCount', + 'container.labels.annotation_io_kubernetes_container_terminationMessagePath', + 'container.labels.annotation_io_kubernetes_container_terminationMessagePolicy', 'container.labels.annotation_io_kubernetes_pod_terminationGracePeriod', + 'container.labels.io_kubernetes_container_logpath', 'container.labels.io_kubernetes_container_name', + 'container.labels.io_kubernetes_docker_type', + 'container.labels.io_kubernetes_pod_name', 'container.labels.io_kubernetes_pod_namespace', + 'container.labels.io_kubernetes_pod_uid', + 'container.labels.io_kubernetes_sandbox_id', + 'container.name', + 'container.runtime', + 'data_stream.dataset', + 'data_stream.namespace', + 'data_stream.type', 'details', + 'ecs.version', + 'elasticapm_labels.span.id', + 'elasticapm_labels.trace.id', + 'elasticapm_labels.transaction.id', 'elasticapm_span_id', + 'elasticapm_trace_id', 'elasticapm_transaction_id', + 'event.category', + 'event.dataset', + 'event.kind', 'event.module', 'event.timezone', + 'event.type', + 'fileset.name', + 'host.architecture', + 'host.containerized', 'host.hostname', + 'host.ip', + 'host.mac', + 'host.name', + 'host.os.codename', 'host.os.family', 'host.os.kernel', 'host.os.name', 'host.os.platform', + 'host.os.type', + 'host.os.version', 'hostname', + 'input.type', 'kubernetes.container.name', + 'kubernetes.labels.app', 'kubernetes.labels.pod-template-hash', + 'kubernetes.namespace', 'kubernetes.namespace_labels.kubernetes_io/metadata_name', 'kubernetes.namespace_uid', + 'kubernetes.node.hostname', 'kubernetes.node.labels.addon_gke_io/node-local-dns-ds-ready', 'kubernetes.node.labels.beta_kubernetes_io/arch', + 'kubernetes.node.labels.beta_kubernetes_io/instance-type', + 'kubernetes.node.labels.beta_kubernetes_io/os', 'kubernetes.node.labels.cloud_google_com/gke-boot-disk', 'kubernetes.node.labels.cloud_google_com/gke-container-runtime', + 'kubernetes.node.labels.cloud_google_com/gke-nodepool', + 'kubernetes.node.labels.cloud_google_com/gke-os-distribution', 'kubernetes.node.labels.cloud_google_com/machine-family', + 'kubernetes.node.labels.failure-domain_beta_kubernetes_io/region', + 'kubernetes.node.labels.failure-domain_beta_kubernetes_io/zone', 'kubernetes.node.labels.kubernetes_io/arch', + 'kubernetes.node.labels.kubernetes_io/hostname', 'kubernetes.node.labels.kubernetes_io/os', 'kubernetes.node.labels.node_kubernetes_io/instance-type', + 'kubernetes.node.labels.node_type', + 'kubernetes.node.labels.topology_kubernetes_io/region', + 'kubernetes.node.labels.topology_kubernetes_io/zone', + 'kubernetes.node.name', + 'kubernetes.node.uid', 'kubernetes.pod.ip', 'kubernetes.pod.name', + 'kubernetes.pod.uid', + 'kubernetes.replicaset.name', + 'labels.userId', 'log.file.path', + 'log.flags', 'log.level', + 'log.logger', + 'log.origin.file.name', + 'log.origin.function', + 'log.original', 'name', 'postgresql.log.database', 'postgresql.log.query', + 'postgresql.log.query_step', 'postgresql.log.timestamp', + 'process.executable', 'process.name', + 'process.thread.name', + 'related.user', + 'req.headers.accept', 'req.headers.accept-encoding', 'req.headers.cache-control', + 'req.headers.connection', + 'req.headers.content-length', + 'req.headers.content-type', + 'req.headers.cookie', + 'req.headers.host', 'req.headers.origin', + 'req.headers.pragma', + 'req.headers.referer', + 'req.headers.traceparent', 'req.headers.tracestate', + 'req.headers.user-agent', 'req.headers.x-real-ip', + 'req.method', + 'req.remoteAddress', + 'req.url', 'service.name', + 'service.type', + 'span.id', 'stack', + 'stream', + 'trace.id', + 'transaction.id', + 'type', 'user.name', ]); expect(textFieldCandidates).toEqual(['error.message', 'message']); @@ -172,6 +271,7 @@ describe('fetch_index_info', () => { 'customer_phone', 'day_of_week', 'email', + 'event.dataset', 'geoip.city_name', 'geoip.continent_name', 'geoip.country_iso_code', diff --git a/x-pack/packages/ml/aiops_log_rate_analysis/queries/fetch_index_info.ts b/x-pack/packages/ml/aiops_log_rate_analysis/queries/fetch_index_info.ts index c1acb2cad6f75..1bb5b701fdd17 100644 --- a/x-pack/packages/ml/aiops_log_rate_analysis/queries/fetch_index_info.ts +++ b/x-pack/packages/ml/aiops_log_rate_analysis/queries/fetch_index_info.ts @@ -25,8 +25,6 @@ const SUPPORTED_ES_FIELD_TYPES = [ const SUPPORTED_ES_FIELD_TYPES_TEXT = [ES_FIELD_TYPES.TEXT, ES_FIELD_TYPES.MATCH_ONLY_TEXT]; -const IGNORE_FIELD_NAMES = ['_tier']; - interface IndexInfo { fieldCandidates: string[]; textFieldCandidates: string[]; @@ -45,9 +43,19 @@ export const fetchIndexInfo = async ( // Get all supported fields const respMapping = await esClient.fieldCaps( { - index, fields: '*', + filters: '-metadata', include_empty_fields: false, + index, + index_filter: { + range: { + [params.timeFieldName]: { + gte: params.deviationMin, + lte: params.deviationMax, + }, + }, + }, + types: [...SUPPORTED_ES_FIELD_TYPES, ...SUPPORTED_ES_FIELD_TYPES_TEXT], }, { signal: abortSignal, maxRetries: 0 } ); @@ -64,11 +72,11 @@ export const fetchIndexInfo = async ( const isTextField = fieldTypes.some((type) => SUPPORTED_ES_FIELD_TYPES_TEXT.includes(type)); // Check if fieldName is something we can aggregate on - if (isSupportedType && isAggregatable && !IGNORE_FIELD_NAMES.includes(key)) { + if (isSupportedType && isAggregatable) { acceptableFields.add(key); } - if (isTextField && !IGNORE_FIELD_NAMES.includes(key)) { + if (isTextField) { acceptableTextFields.add(key); } diff --git a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts index eec2762e3fd81..2886aa1babcef 100644 --- a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts +++ b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts @@ -29,6 +29,6 @@ export const connectorTypes: string[] = [ '.bedrock', '.d3security', '.sentinelone', - '.observability-ai-assistant', '.cases', + '.observability-ai-assistant', ]; diff --git a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts index 397d49a2751cf..7d029e211b580 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts @@ -39,6 +39,12 @@ import type { RuleParams } from '../../application/rule/types'; import { ruleDomainSchema } from '../../application/rule/schemas'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +/** + * Updating too many rules in parallel can cause the denial of service of the + * Elasticsearch cluster. + */ +const MAX_RULES_TO_UPDATE_IN_PARALLEL = 50; + const getShouldScheduleTask = async ( context: RulesClientContext, scheduledTaskId: string | null | undefined @@ -183,114 +189,120 @@ const bulkEnableRulesWithOCC = async ( }); } - await pMap(rulesFinderRules, async (rule) => { - try { - if (scheduleValidationError) { - throw Error(scheduleValidationError); - } - if (rule.attributes.actions.length) { - try { - await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' }); - } catch (error) { - throw Error(`Rule not authorized for bulk enable - ${error.message}`); + await pMap( + rulesFinderRules, + async (rule) => { + try { + if (scheduleValidationError) { + throw Error(scheduleValidationError); + } + if (rule.attributes.actions.length) { + try { + await context.actionsAuthorization.ensureAuthorized({ operation: 'execute' }); + } catch (error) { + throw Error(`Rule not authorized for bulk enable - ${error.message}`); + } + } + if (rule.attributes.name) { + ruleNameToRuleIdMapping[rule.id] = rule.attributes.name; } - } - if (rule.attributes.name) { - ruleNameToRuleIdMapping[rule.id] = rule.attributes.name; - } - const migratedActions = await migrateLegacyActions(context, { - ruleId: rule.id, - actions: rule.attributes.actions, - references: rule.references, - attributes: rule.attributes, - }); - - const updatedAttributes = updateMeta(context, { - ...rule.attributes, - ...(!rule.attributes.apiKey && - (await createNewAPIKeySet(context, { - id: rule.attributes.alertTypeId, - ruleName: rule.attributes.name, - username, - shouldUpdateApiKey: true, - }))), - ...(migratedActions.hasLegacyActions - ? { - actions: migratedActions.resultedActions, - throttle: undefined, - notifyWhen: undefined, - } - : {}), - enabled: true, - updatedBy: username, - updatedAt: new Date().toISOString(), - executionStatus: { - status: 'pending', - lastDuration: 0, - lastExecutionDate: new Date().toISOString(), - error: null, - warning: null, - }, - scheduledTaskId: rule.id, - }); - - const shouldScheduleTask = await getShouldScheduleTask( - context, - rule.attributes.scheduledTaskId - ); - - if (shouldScheduleTask) { - tasksToSchedule.push({ - id: rule.id, - taskType: `alerting:${rule.attributes.alertTypeId}`, - schedule: rule.attributes.schedule, - params: { - alertId: rule.id, - spaceId: context.spaceId, - consumer: rule.attributes.consumer, + const migratedActions = await migrateLegacyActions(context, { + ruleId: rule.id, + actions: rule.attributes.actions, + references: rule.references, + attributes: rule.attributes, + }); + + const updatedAttributes = updateMeta(context, { + ...rule.attributes, + ...(!rule.attributes.apiKey && + (await createNewAPIKeySet(context, { + id: rule.attributes.alertTypeId, + ruleName: rule.attributes.name, + username, + shouldUpdateApiKey: true, + }))), + ...(migratedActions.hasLegacyActions + ? { + actions: migratedActions.resultedActions, + throttle: undefined, + notifyWhen: undefined, + } + : {}), + enabled: true, + updatedBy: username, + updatedAt: new Date().toISOString(), + executionStatus: { + status: 'pending', + lastDuration: 0, + lastExecutionDate: new Date().toISOString(), + error: null, + warning: null, }, - state: { - previousStartedAt: null, - alertTypeState: {}, - alertInstances: {}, + scheduledTaskId: rule.id, + }); + + const shouldScheduleTask = await getShouldScheduleTask( + context, + rule.attributes.scheduledTaskId + ); + + if (shouldScheduleTask) { + tasksToSchedule.push({ + id: rule.id, + taskType: `alerting:${rule.attributes.alertTypeId}`, + schedule: rule.attributes.schedule, + params: { + alertId: rule.id, + spaceId: context.spaceId, + consumer: rule.attributes.consumer, + }, + state: { + previousStartedAt: null, + alertTypeState: {}, + alertInstances: {}, + }, + scope: ['alerting'], + enabled: false, // we create the task as disabled, taskManager.bulkEnable will enable them by randomising their schedule datetime + }); + } + + rulesToEnable.push({ + ...rule, + attributes: updatedAttributes, + ...(migratedActions.hasLegacyActions + ? { references: migratedActions.resultedReferences } + : {}), + }); + + context.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.ENABLE, + outcome: 'unknown', + savedObject: { type: RULE_SAVED_OBJECT_TYPE, id: rule.id }, + }) + ); + } catch (error) { + errors.push({ + message: error.message, + rule: { + id: rule.id, + name: rule.attributes?.name, }, - scope: ['alerting'], - enabled: false, // we create the task as disabled, taskManager.bulkEnable will enable them by randomising their schedule datetime }); + context.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.ENABLE, + error, + }) + ); } - - rulesToEnable.push({ - ...rule, - attributes: updatedAttributes, - ...(migratedActions.hasLegacyActions - ? { references: migratedActions.resultedReferences } - : {}), - }); - - context.auditLogger?.log( - ruleAuditEvent({ - action: RuleAuditAction.ENABLE, - outcome: 'unknown', - savedObject: { type: RULE_SAVED_OBJECT_TYPE, id: rule.id }, - }) - ); - } catch (error) { - errors.push({ - message: error.message, - rule: { - id: rule.id, - name: rule.attributes?.name, - }, - }); - context.auditLogger?.log( - ruleAuditEvent({ - action: RuleAuditAction.ENABLE, - error, - }) - ); + }, + { + concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, } - }); + ); } ); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/kibana.jsonc b/x-pack/plugins/observability_solution/observability_ai_assistant/kibana.jsonc index 1968c772eccfb..39af4d91bc87b 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/kibana.jsonc @@ -14,9 +14,6 @@ "security", "taskManager", "dataViews", - "triggersActionsUi", - "ruleRegistry", - "alerting" ], "requiredBundles": ["kibanaReact", "kibanaUtils"], "optionalPlugins": ["cloud", "serverless"], diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts index 91f1c640141f9..d42c96715523e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts @@ -31,6 +31,7 @@ export type { export { AssistantAvatar } from './components/assistant_avatar'; export { ConnectorSelectorBase } from './components/connector_selector/connector_selector_base'; export { useAbortableAsync, type AbortableAsyncState } from './hooks/use_abortable_async'; +export { useGenAIConnectorsWithoutContext } from './hooks/use_genai_connectors'; export { createStorybookChatService, createStorybookService } from './storybook_mock'; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/plugin.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant/public/plugin.tsx index ccd67edc58dd6..2b9ab400a08f8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/plugin.tsx @@ -29,7 +29,6 @@ import type { ObservabilityAIAssistantPublicStart, ObservabilityAIAssistantService, } from './types'; -import { getObsAIAssistantConnectorType } from './rule_connector'; export class ObservabilityAIAssistantPlugin implements @@ -88,10 +87,6 @@ export class ObservabilityAIAssistantPlugin const isEnabled = service.isEnabled(); - pluginsStart.triggersActionsUi.actionTypeRegistry.register( - getObsAIAssistantConnectorType(service) - ); - return { service, useGenAIConnectors: () => useGenAIConnectorsWithoutContext(service), diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts index 77075da8d3195..4e3a0b8ecabd7 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts @@ -8,7 +8,6 @@ import { CoreSetup, DEFAULT_APP_CATEGORIES, - KibanaRequest, Logger, Plugin, PluginInitializerContext, @@ -24,10 +23,7 @@ import { firstValueFrom } from 'rxjs'; import { OBSERVABILITY_AI_ASSISTANT_FEATURE_ID } from '../common/feature'; import type { ObservabilityAIAssistantConfig } from './config'; import { registerServerRoutes } from './routes/register_routes'; -import { - ObservabilityAIAssistantRequestHandlerContext, - ObservabilityAIAssistantRouteHandlerResources, -} from './routes/types'; +import { ObservabilityAIAssistantRouteHandlerResources } from './routes/types'; import { ObservabilityAIAssistantService } from './service'; import { ObservabilityAIAssistantServerSetup, @@ -38,10 +34,6 @@ import { import { addLensDocsToKb } from './service/knowledge_base_service/kb_docs/lens'; import { registerFunctions } from './functions'; import { recallRankingEvent } from './analytics/recall_ranking'; -import { - getObsAIAssistantConnectorType, - getObsAIAssistantConnectorAdapter, -} from './rule_connector'; export class ObservabilityAIAssistantPlugin implements @@ -162,55 +154,6 @@ export class ObservabilityAIAssistantPlugin addLensDocsToKb({ service, logger: this.logger.get('kb').get('lens') }); - const initResources = async ( - request: KibanaRequest - ): Promise => { - const [coreStart, pluginsStart] = await core.getStartServices(); - const license = await firstValueFrom(pluginsStart.licensing.license$); - const savedObjectsClient = coreStart.savedObjects.getScopedClient(request); - - const context: ObservabilityAIAssistantRequestHandlerContext = { - rac: routeHandlerPlugins.ruleRegistry.start().then((startContract) => { - return { - getAlertsClient() { - return startContract.getRacClientWithRequest(request); - }, - }; - }), - alerting: routeHandlerPlugins.alerting.start().then((startContract) => { - return { - getRulesClient() { - return startContract.getRulesClientWithRequest(request); - }, - }; - }), - core: Promise.resolve({ - coreStart, - elasticsearch: { - client: coreStart.elasticsearch.client.asScoped(request), - }, - uiSettings: { - client: coreStart.uiSettings.asScopedToClient(savedObjectsClient), - }, - savedObjects: { - client: savedObjectsClient, - }, - }), - licensing: Promise.resolve({ license, featureUsage: pluginsStart.licensing.featureUsage }), - }; - - return { - request, - service, - context, - logger: this.logger.get('connector'), - plugins: routeHandlerPlugins, - }; - }; - - plugins.actions.registerType(getObsAIAssistantConnectorType(initResources)); - plugins.alerting.registerConnectorAdapter(getObsAIAssistantConnectorAdapter()); - registerServerRoutes({ core, logger: this.logger, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json index b6502b6ef5517..eee8ea0d56911 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json @@ -48,7 +48,6 @@ "@kbn/cloud-plugin", "@kbn/serverless", "@kbn/triggers-actions-ui-plugin", - "@kbn/stack-connectors-plugin", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/common/rule_connector.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/common/rule_connector.ts new file mode 100644 index 0000000000000..84c7b1f3e8d64 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/common/rule_connector.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 const OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID = '.observability-ai-assistant'; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc index f4aac7df8a389..b64d31e3f13b9 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc @@ -21,7 +21,9 @@ "share", "security", "licensing", - "ml" + "ml", + "alerting", + "features" ], "requiredBundles": ["kibanaReact"], "optionalPlugins": ["cloud"], diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx index 1e226aa99c8a8..6e188043a6a31 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/plugin.tsx @@ -26,6 +26,7 @@ import type { import { createAppService, ObservabilityAIAssistantAppService } from './service/create_app_service'; import { SharedProviders } from './utils/shared_providers'; import { LazyNavControl } from './components/nav_control/lazy_nav_control'; +import { getObsAIAssistantConnectorType } from './rule_connector'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ConfigSchema {} @@ -124,12 +125,17 @@ export class ObservabilityAIAssistantAppPlugin order: 1001, }); - pluginsStart.observabilityAIAssistant.service.register(async ({ registerRenderFunction }) => { + const service = pluginsStart.observabilityAIAssistant.service; + + service.register(async ({ registerRenderFunction }) => { const { registerFunctions } = await import('./functions'); await registerFunctions({ pluginsStart, registerRenderFunction }); }); + pluginsStart.triggersActionsUi.actionTypeRegistry.register( + getObsAIAssistantConnectorType(service) + ); return {}; } } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/ai_assistant.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/ai_assistant.tsx similarity index 93% rename from x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/ai_assistant.tsx rename to x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/ai_assistant.tsx index beadd590ce2f5..bd024e9231ac0 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/ai_assistant.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/ai_assistant.tsx @@ -10,10 +10,12 @@ import type { ActionTypeModel as ConnectorTypeModel, GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public/types'; -import { ObsAIAssistantActionParams } from './types'; -import { ObservabilityAIAssistantService } from '../types'; -import { AssistantAvatar } from '../components/assistant_avatar'; +import { + AssistantAvatar, + ObservabilityAIAssistantService, +} from '@kbn/observability-ai-assistant-plugin/public'; import { OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID } from '../../common/rule_connector'; +import { ObsAIAssistantActionParams } from './types'; import { CONNECTOR_DESC, CONNECTOR_REQUIRED, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/ai_assistant_params.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/ai_assistant_params.tsx similarity index 94% rename from x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/ai_assistant_params.tsx rename to x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/ai_assistant_params.tsx index 95793f95a437d..4e3683625e819 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/ai_assistant_params.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/ai_assistant_params.tsx @@ -9,9 +9,11 @@ import React, { useEffect } from 'react'; import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFlexItem, EuiSelect, EuiSpacer, EuiTextArea } from '@elastic/eui'; +import { + ObservabilityAIAssistantService, + useGenAIConnectorsWithoutContext, +} from '@kbn/observability-ai-assistant-plugin/public'; import { ObsAIAssistantActionParams } from './types'; -import { ObservabilityAIAssistantService } from '../types'; -import { useGenAIConnectorsWithoutContext } from '../hooks/use_genai_connectors'; const ObsAIAssistantParamsFields: React.FunctionComponent< ActionParamsProps & { service: ObservabilityAIAssistantService } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/index.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/index.ts rename to x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/index.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/translations.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/translations.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/translations.ts rename to x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/translations.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/types.ts similarity index 100% rename from x-pack/plugins/observability_solution/observability_ai_assistant/public/rule_connector/types.ts rename to x-pack/plugins/observability_solution/observability_ai_assistant_app/public/rule_connector/types.ts diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts index 097c50bad0bd9..903c3c0c26826 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts @@ -11,9 +11,21 @@ import { Plugin, type PluginInitializerContext, type CoreStart, + KibanaRequest, } from '@kbn/core/server'; +import { + ObservabilityAIAssistantRequestHandlerContext, + ObservabilityAIAssistantRouteHandlerResources, +} from '@kbn/observability-ai-assistant-plugin/server/routes/types'; +import { ObservabilityAIAssistantPluginStartDependencies } from '@kbn/observability-ai-assistant-plugin/server/types'; +import { mapValues } from 'lodash'; +import { firstValueFrom } from 'rxjs'; import type { ObservabilityAIAssistantAppConfig } from './config'; import { registerFunctions } from './functions'; +import { + getObsAIAssistantConnectorAdapter, + getObsAIAssistantConnectorType, +} from './rule_connector'; import type { ObservabilityAIAssistantAppPluginSetupDependencies, ObservabilityAIAssistantAppPluginStartDependencies, @@ -42,6 +54,68 @@ export class ObservabilityAIAssistantAppPlugin >, plugins: ObservabilityAIAssistantAppPluginSetupDependencies ): ObservabilityAIAssistantAppServerSetup { + const routeHandlerPlugins = mapValues(plugins, (value, key) => { + return { + setup: value, + start: () => + core.getStartServices().then((services) => { + const [, pluginsStartContracts] = services; + return pluginsStartContracts[ + key as keyof ObservabilityAIAssistantPluginStartDependencies + ]; + }), + }; + }) as ObservabilityAIAssistantRouteHandlerResources['plugins']; + + const initResources = async ( + request: KibanaRequest + ): Promise => { + const [coreStart, pluginsStart] = await core.getStartServices(); + const license = await firstValueFrom(pluginsStart.licensing.license$); + const savedObjectsClient = coreStart.savedObjects.getScopedClient(request); + + const context: ObservabilityAIAssistantRequestHandlerContext = { + rac: routeHandlerPlugins.ruleRegistry.start().then((startContract) => { + return { + getAlertsClient() { + return startContract.getRacClientWithRequest(request); + }, + }; + }), + alerting: routeHandlerPlugins.alerting.start().then((startContract) => { + return { + getRulesClient() { + return startContract.getRulesClientWithRequest(request); + }, + }; + }), + core: Promise.resolve({ + coreStart, + elasticsearch: { + client: coreStart.elasticsearch.client.asScoped(request), + }, + uiSettings: { + client: coreStart.uiSettings.asScopedToClient(savedObjectsClient), + }, + savedObjects: { + client: savedObjectsClient, + }, + }), + licensing: Promise.resolve({ license, featureUsage: pluginsStart.licensing.featureUsage }), + }; + + return { + request, + context, + service: plugins.observabilityAIAssistant.service, + logger: this.logger.get('connector'), + plugins: routeHandlerPlugins, + }; + }; + + plugins.actions.registerType(getObsAIAssistantConnectorType(initResources)); + plugins.alerting.registerConnectorAdapter(getObsAIAssistantConnectorAdapter()); + return {}; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/rule_connector/convert_schema_to_open_api.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/convert_schema_to_open_api.ts similarity index 93% rename from x-pack/plugins/observability_solution/observability_ai_assistant/server/rule_connector/convert_schema_to_open_api.ts rename to x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/convert_schema_to_open_api.ts index 44b3c80a9a33f..89a49e6081b5d 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/rule_connector/convert_schema_to_open_api.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/convert_schema_to_open_api.ts @@ -8,7 +8,7 @@ import joiToJsonSchema from 'joi-to-json'; import type { Type } from '@kbn/config-schema'; import { castArray, isPlainObject, forEach, unset } from 'lodash'; -import type { CompatibleJSONSchema } from '../../common/functions/types'; +import type { CompatibleJSONSchema } from '@kbn/observability-ai-assistant-plugin/common/functions/types'; function dropUnknownProperties(object: CompatibleJSONSchema) { if (!isPlainObject(object)) { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.test.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.test.ts new file mode 100644 index 0000000000000..7990f353062da --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertHit } from '@kbn/alerting-plugin/server/types'; +import { ObservabilityAIAssistantRouteHandlerResources } from '@kbn/observability-ai-assistant-plugin/server/routes/types'; +import { getFakeKibanaRequest } from '@kbn/security-plugin/server/authentication/api_keys/fake_kibana_request'; +import { OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID } from '../../common/rule_connector'; +import { + getObsAIAssistantConnectorAdapter, + getObsAIAssistantConnectorType, + ObsAIAssistantConnectorTypeExecutorOptions, +} from '.'; +import { Observable } from 'rxjs'; +import { MessageRole } from '@kbn/observability-ai-assistant-plugin/public'; + +describe('observabilityAIAssistant rule_connector', () => { + describe('getObsAIAssistantConnectorAdapter', () => { + it('uses correct connector_id', () => { + const adapter = getObsAIAssistantConnectorAdapter(); + expect(adapter.connectorTypeId).toEqual(OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID); + }); + + it('builds action params', () => { + const adapter = getObsAIAssistantConnectorAdapter(); + const params = adapter.buildActionParams({ + params: { connector: '.azure', message: 'hello' }, + rule: { id: 'foo', name: 'bar', tags: [], consumer: '', producer: '' }, + ruleUrl: 'http://myrule.com', + spaceId: 'default', + alerts: { + all: { count: 1, data: [] }, + new: { count: 1, data: [{ _id: 'new_alert' } as AlertHit] }, + ongoing: { count: 1, data: [] }, + recovered: { count: 1, data: [{ _id: 'recovered_alert' } as AlertHit] }, + }, + }); + + expect(params).toEqual({ + connector: '.azure', + message: 'hello', + rule: { id: 'foo', name: 'bar', tags: [], ruleUrl: 'http://myrule.com' }, + alerts: { + new: [{ _id: 'new_alert' }], + recovered: [{ _id: 'recovered_alert' }], + }, + }); + }); + }); + + describe('getObsAIAssistantConnectorType', () => { + it('is correctly configured', () => { + const initResources = jest + .fn() + .mockResolvedValue({} as ObservabilityAIAssistantRouteHandlerResources); + const connectorType = getObsAIAssistantConnectorType(initResources); + expect(connectorType.id).toEqual(OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID); + expect(connectorType.isSystemActionType).toEqual(true); + expect(connectorType.minimumLicenseRequired).toEqual('enterprise'); + }); + + it('does not execute when no new or recovered alerts', async () => { + const initResources = jest + .fn() + .mockResolvedValue({} as ObservabilityAIAssistantRouteHandlerResources); + const connectorType = getObsAIAssistantConnectorType(initResources); + const result = await connectorType.executor({ + actionId: 'observability-ai-assistant', + request: getFakeKibanaRequest({ id: 'foo', api_key: 'bar' }), + params: { alerts: { new: [], recovered: [] } }, + } as unknown as ObsAIAssistantConnectorTypeExecutorOptions); + expect(result).toEqual({ actionId: 'observability-ai-assistant', status: 'ok' }); + expect(initResources).not.toHaveBeenCalled(); + }); + + it('calls complete api', async () => { + const completeMock = jest.fn().mockReturnValue(new Observable()); + const initResources = jest.fn().mockResolvedValue({ + service: { + getClient: async () => ({ complete: completeMock }), + getFunctionClient: async () => ({ + getContexts: () => [{ name: 'core', description: 'my_system_message' }], + }), + }, + context: { + core: Promise.resolve({ + coreStart: { http: { basePath: { publicBaseUrl: 'http://kibana.com' } } }, + }), + }, + plugins: { + actions: { + start: async () => { + return { + getActionsClientWithRequest: jest.fn().mockResolvedValue({ + async getAll() { + return [{ id: 'connector_1' }]; + }, + }), + }; + }, + }, + }, + } as unknown as ObservabilityAIAssistantRouteHandlerResources); + + const connectorType = getObsAIAssistantConnectorType(initResources); + const result = await connectorType.executor({ + actionId: 'observability-ai-assistant', + request: getFakeKibanaRequest({ id: 'foo', api_key: 'bar' }), + params: { + message: 'hello', + connector: 'azure-open-ai', + alerts: { new: [{ _id: 'new_alert' }], recovered: [] }, + }, + } as unknown as ObsAIAssistantConnectorTypeExecutorOptions); + + expect(result).toEqual({ actionId: 'observability-ai-assistant', status: 'ok' }); + expect(initResources).toHaveBeenCalledTimes(1); + expect(completeMock).toHaveBeenCalledTimes(1); + expect(completeMock).toHaveBeenCalledWith( + expect.objectContaining({ + persist: true, + isPublic: true, + connectorId: 'azure-open-ai', + kibanaPublicUrl: 'http://kibana.com', + messages: [ + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.System, + content: 'my_system_message', + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.User, + content: 'hello', + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.Assistant, + content: '', + function_call: { + name: 'get_connectors', + arguments: JSON.stringify({}), + trigger: MessageRole.Assistant as const, + }, + }, + }, + { + '@timestamp': expect.any(String), + message: { + role: MessageRole.User, + name: 'get_connectors', + content: JSON.stringify({ connectors: [{ id: 'connector_1' }] }), + }, + }, + ], + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/rule_connector/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts similarity index 95% rename from x-pack/plugins/observability_solution/observability_ai_assistant/server/rule_connector/index.ts rename to x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts index ee3eac655fa84..567c326945ef8 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/rule_connector/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts @@ -24,16 +24,16 @@ import { SlackParamsSchema, WebhookParamsSchema, } from '@kbn/stack-connectors-plugin/server'; -import { ObservabilityAIAssistantRouteHandlerResources } from '../routes/types'; +import { ObservabilityAIAssistantRouteHandlerResources } from '@kbn/observability-ai-assistant-plugin/server/routes/types'; import { ChatCompletionChunkEvent, MessageRole, StreamingChatResponseEventType, -} from '../../common'; -import { OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID } from '../../common/rule_connector'; -import { concatenateChatCompletionChunks } from '../../common/utils/concatenate_chat_completion_chunks'; +} from '@kbn/observability-ai-assistant-plugin/common'; +import { concatenateChatCompletionChunks } from '@kbn/observability-ai-assistant-plugin/common/utils/concatenate_chat_completion_chunks'; +import { CompatibleJSONSchema } from '@kbn/observability-ai-assistant-plugin/common/functions/types'; import { convertSchemaToOpenApi } from './convert_schema_to_open_api'; -import { CompatibleJSONSchema } from '../../common/functions/types'; +import { OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID } from '../../common/rule_connector'; const CONNECTOR_PRIVILEGES = ['api:observabilityAIAssistant', 'app:observabilityAIAssistant']; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts index 996279329ab90..902774bacd800 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/types.ts @@ -5,6 +5,23 @@ * 2.0. */ +import type { + PluginSetupContract as ActionsPluginSetup, + PluginStartContract as ActionsPluginStart, +} from '@kbn/actions-plugin/server'; +import type { + PluginSetupContract as AlertingPluginSetup, + PluginStartContract as AlertingPluginStart, +} from '@kbn/alerting-plugin/server'; +import type { + DataViewsServerPluginSetup, + DataViewsServerPluginStart, +} from '@kbn/data-views-plugin/server'; +import type { + PluginStartContract as FeaturesPluginStart, + PluginSetupContract as FeaturesPluginSetup, +} from '@kbn/features-plugin/server'; +import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server'; import type { ObservabilityAIAssistantServerSetup, ObservabilityAIAssistantServerStart, @@ -13,6 +30,13 @@ import type { RuleRegistryPluginSetupContract, RuleRegistryPluginStartContract, } from '@kbn/rule-registry-plugin/server'; +import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/server'; +import type { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ObservabilityAIAssistantAppServerStart {} @@ -22,9 +46,27 @@ export interface ObservabilityAIAssistantAppServerSetup {} export interface ObservabilityAIAssistantAppPluginStartDependencies { observabilityAIAssistant: ObservabilityAIAssistantServerStart; ruleRegistry: RuleRegistryPluginStartContract; + alerting: AlertingPluginStart; + licensing: LicensingPluginStart; + actions: ActionsPluginStart; + security: SecurityPluginStart; + features: FeaturesPluginStart; + taskManager: TaskManagerStartContract; + dataViews: DataViewsServerPluginStart; + cloud?: CloudStart; + serverless?: ServerlessPluginStart; } export interface ObservabilityAIAssistantAppPluginSetupDependencies { observabilityAIAssistant: ObservabilityAIAssistantServerSetup; ruleRegistry: RuleRegistryPluginSetupContract; + alerting: AlertingPluginSetup; + licensing: LicensingPluginSetup; + actions: ActionsPluginSetup; + security: SecurityPluginSetup; + features: FeaturesPluginSetup; + taskManager: TaskManagerSetupContract; + dataViews: DataViewsServerPluginSetup; + cloud?: CloudSetup; + serverless?: ServerlessPluginSetup; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json index e35d24bc573ed..8c7224647ce24 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json @@ -55,7 +55,13 @@ "@kbn/ai-assistant-management-plugin", "@kbn/deeplinks-observability", "@kbn/management-settings-ids", - "@kbn/apm-utils" + "@kbn/apm-utils", + "@kbn/alerting-plugin", + "@kbn/stack-connectors-plugin", + "@kbn/features-plugin", + "@kbn/serverless", + "@kbn/task-manager-plugin", + "@kbn/cloud-plugin" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.mock.ts index fa4fcefbcad1a..0a7cd906dc4bb 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.mock.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.mock.ts @@ -8,7 +8,7 @@ import type { PerformBulkActionRequestBody } from './bulk_actions_route.gen'; import { BulkActionEditTypeEnum, BulkActionTypeEnum } from './bulk_actions_route.gen'; -export const getPerformBulkActionSchemaMock = (): PerformBulkActionRequestBody => ({ +export const getBulkDisableRuleActionSchemaMock = (): PerformBulkActionRequestBody => ({ query: '', ids: undefined, action: BulkActionTypeEnum.disable, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index aa3da4e614f14..82a40cbf71a41 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -31,7 +31,7 @@ import { PREBUILT_RULES_URL, } from '../../../../../common/api/detection_engine/prebuilt_rules'; import { - getPerformBulkActionSchemaMock, + getBulkDisableRuleActionSchemaMock, getPerformBulkActionEditSchemaMock, } from '../../../../../common/api/detection_engine/rule_management/mocks'; @@ -131,11 +131,11 @@ export const getPatchBulkRequest = () => body: [getCreateRulesSchemaMock()], }); -export const getBulkActionRequest = () => +export const getBulkDisableRuleActionRequest = () => requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, - body: getPerformBulkActionSchemaMock(), + body: getBulkDisableRuleActionSchemaMock(), }); export const getBulkActionEditRequest = () => diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_actions_response.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_actions_response.ts new file mode 100644 index 0000000000000..5485c06cc4a27 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_actions_response.ts @@ -0,0 +1,145 @@ +/* + * 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 { BulkActionSkipResult } from '@kbn/alerting-plugin/common'; +import type { BulkOperationError } from '@kbn/alerting-plugin/server'; +import type { IKibanaResponse, KibanaResponseFactory } from '@kbn/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { truncate } from 'lodash'; +import type { + BulkEditActionResults, + BulkEditActionSummary, + NormalizedRuleError, + RuleDetailsInError, +} from '../../../../../../../common/api/detection_engine'; +import type { BulkEditActionResponse } from '../../../../../../../common/api/detection_engine/rule_management'; +import type { BulkActionsDryRunErrCode } from '../../../../../../../common/constants'; +import type { PromisePoolError } from '../../../../../../utils/promise_pool'; +import type { RuleAlertType } from '../../../../rule_schema'; +import type { DryRunError } from '../../../logic/bulk_actions/dry_run'; +import { internalRuleToAPIResponse } from '../../../normalization/rule_converters'; + +const MAX_ERROR_MESSAGE_LENGTH = 1000; + +export type BulkActionError = + | PromisePoolError + | PromisePoolError + | BulkOperationError; + +export const buildBulkResponse = ( + response: KibanaResponseFactory, + { + isDryRun = false, + errors = [], + updated = [], + created = [], + deleted = [], + skipped = [], + }: { + isDryRun?: boolean; + errors?: BulkActionError[]; + updated?: RuleAlertType[]; + created?: RuleAlertType[]; + deleted?: RuleAlertType[]; + skipped?: BulkActionSkipResult[]; + } +): IKibanaResponse => { + const numSucceeded = updated.length + created.length + deleted.length; + const numSkipped = skipped.length; + const numFailed = errors.length; + + const summary: BulkEditActionSummary = { + failed: numFailed, + succeeded: numSucceeded, + skipped: numSkipped, + total: numSucceeded + numFailed + numSkipped, + }; + + // if response is for dry_run, empty lists of rules returned, as rules are not actually updated and stored within ES + // thus, it's impossible to return reliably updated/duplicated/deleted rules + const results: BulkEditActionResults = isDryRun + ? { + updated: [], + created: [], + deleted: [], + skipped: [], + } + : { + updated: updated.map((rule) => internalRuleToAPIResponse(rule)), + created: created.map((rule) => internalRuleToAPIResponse(rule)), + deleted: deleted.map((rule) => internalRuleToAPIResponse(rule)), + skipped, + }; + + if (numFailed > 0) { + return response.custom({ + headers: { 'content-type': 'application/json' }, + body: { + message: summary.succeeded > 0 ? 'Bulk edit partially failed' : 'Bulk edit failed', + status_code: 500, + attributes: { + errors: normalizeErrorResponse(errors), + results, + summary, + }, + }, + statusCode: 500, + }); + } + + const responseBody: BulkEditActionResponse = { + success: true, + rules_count: summary.total, + attributes: { results, summary }, + }; + + return response.ok({ body: responseBody }); +}; + +export const normalizeErrorResponse = (errors: BulkActionError[]): NormalizedRuleError[] => { + const errorsMap = new Map(); + + errors.forEach((errorObj) => { + let message: string; + let statusCode: number = 500; + let errorCode: BulkActionsDryRunErrCode | undefined; + let rule: RuleDetailsInError; + // transform different error types (PromisePoolError | PromisePoolError | BulkOperationError) + // to one common used in NormalizedRuleError + if ('rule' in errorObj) { + rule = errorObj.rule; + message = errorObj.message; + } else { + const { error, item } = errorObj; + const transformedError = + error instanceof Error + ? transformError(error) + : { message: String(error), statusCode: 500 }; + + errorCode = (error as DryRunError)?.errorCode; + message = transformedError.message; + statusCode = transformedError.statusCode; + // The promise pool item is either a rule ID string or a rule object. We have + // string IDs when we fail to fetch rules. Rule objects come from other + // situations when we found a rule but failed somewhere else. + rule = typeof item === 'string' ? { id: item } : { id: item.id, name: item.name }; + } + + if (errorsMap.has(message)) { + errorsMap.get(message)?.rules.push(rule); + } else { + errorsMap.set(message, { + message: truncate(message, { length: MAX_ERROR_MESSAGE_LENGTH }), + status_code: statusCode, + err_code: errorCode, + rules: [rule], + }); + } + }); + + return Array.from(errorsMap, ([_, normalizedError]) => normalizedError); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_enable_disable_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_enable_disable_rules.ts new file mode 100644 index 0000000000000..670e72d8663d4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/bulk_enable_disable_rules.ts @@ -0,0 +1,105 @@ +/* + * 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 { RulesClient } from '@kbn/alerting-plugin/server'; +import { invariant } from '../../../../../../../common/utils/invariant'; +import type { PromisePoolError } from '../../../../../../utils/promise_pool'; +import type { MlAuthz } from '../../../../../machine_learning/authz'; +import type { RuleAlertType } from '../../../../rule_schema'; +import { validateBulkEnableRule } from '../../../logic/bulk_actions/validations'; + +interface BulkEnableDisableRulesArgs { + rules: RuleAlertType[]; + action: 'enable' | 'disable'; + isDryRun?: boolean; + rulesClient: RulesClient; + mlAuthz: MlAuthz; +} + +interface BulkEnableDisableRulesOutcome { + updatedRules: RuleAlertType[]; + errors: Array>; +} + +export const bulkEnableDisableRules = async ({ + rules, + isDryRun, + rulesClient, + action: operation, + mlAuthz, +}: BulkEnableDisableRulesArgs): Promise => { + const errors: Array> = []; + const updatedRules: RuleAlertType[] = []; + + // In the first step, we validate if the rules can be enabled + const validatedRules: RuleAlertType[] = []; + await Promise.all( + rules.map(async (rule) => { + try { + await validateBulkEnableRule({ mlAuthz, rule }); + validatedRules.push(rule); + } catch (error) { + errors.push({ item: rule, error }); + } + }) + ); + + if (isDryRun || validatedRules.length === 0) { + return { + updatedRules: validatedRules, + errors, + }; + } + + // Then if it's not a dry run, we enable the rules that passed the validation + const ruleIds = validatedRules.map(({ id }) => id); + + // Perform actual update using the rulesClient + const results = + operation === 'enable' + ? await rulesClient.bulkEnableRules({ ids: ruleIds }) + : await rulesClient.bulkDisableRules({ ids: ruleIds }); + + const failedRuleIds = results.errors.map(({ rule: { id } }) => id); + + // We need to go through the original rules array and update rules that were + // not returned as failed from the bulkEnableRules. We cannot rely on the + // results from the bulkEnableRules because the response is not consistent. + // Some rules might be missing in the response if they were skipped by + // Alerting Framework. See this issue for more details: + // https://github.com/elastic/kibana/issues/181050 + updatedRules.push( + ...rules.flatMap((rule) => { + if (failedRuleIds.includes(rule.id)) { + return []; + } + return { + ...rule, + enabled: operation === 'enable', + }; + }) + ); + + // Rule objects returned from the bulkEnableRules are not + // compatible with the response type. So we need to map them to + // the original rules and update the enabled field + errors.push( + ...results.errors.map(({ rule: { id }, message }) => { + const rule = rules.find((r) => r.id === id); + invariant(rule != null, 'Unexpected rule id'); + return { + item: rule, + error: new Error(message), + }; + }) + ); + + return { + updatedRules, + errors, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts new file mode 100644 index 0000000000000..2a07ecc95509b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts @@ -0,0 +1,64 @@ +/* + * 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 { RulesClient } from '@kbn/alerting-plugin/server'; +import { BadRequestError } from '@kbn/securitysolution-es-utils'; +import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../../../../common/constants'; +import type { PromisePoolOutcome } from '../../../../../../utils/promise_pool'; +import { initPromisePool } from '../../../../../../utils/promise_pool'; +import type { RuleAlertType } from '../../../../rule_schema'; +import { readRules } from '../../../logic/crud/read_rules'; +import { findRules } from '../../../logic/search/find_rules'; +import { MAX_RULES_TO_PROCESS_TOTAL } from './route'; + +export const fetchRulesByQueryOrIds = async ({ + query, + ids, + rulesClient, + abortSignal, +}: { + query: string | undefined; + ids: string[] | undefined; + rulesClient: RulesClient; + abortSignal: AbortSignal; +}): Promise> => { + if (ids) { + return initPromisePool({ + concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, + items: ids, + executor: async (id: string) => { + const rule = await readRules({ id, rulesClient, ruleId: undefined }); + if (rule == null) { + throw Error('Rule not found'); + } + return rule; + }, + abortSignal, + }); + } + + const { data, total } = await findRules({ + rulesClient, + perPage: MAX_RULES_TO_PROCESS_TOTAL, + filter: query, + page: undefined, + sortField: undefined, + sortOrder: undefined, + fields: undefined, + }); + + if (total > MAX_RULES_TO_PROCESS_TOTAL) { + throw new BadRequestError( + `More than ${MAX_RULES_TO_PROCESS_TOTAL} rules matched the filter query. Try to narrow it down.` + ); + } + + return { + results: data.map((rule) => ({ item: rule.id, result: rule })), + errors: [], + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts index cd01a251a3c75..576ba1376ba3c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts @@ -13,7 +13,7 @@ import { mlServicesMock } from '../../../../../machine_learning/mocks'; import { buildMlAuthz } from '../../../../../machine_learning/authz'; import { getEmptyFindResult, - getBulkActionRequest, + getBulkDisableRuleActionRequest, getBulkActionEditRequest, getFindResultWithSingleHit, getFindResultWithMultiHits, @@ -22,7 +22,7 @@ import { requestContextMock, serverMock, requestMock } from '../../../../routes/ import { performBulkActionRoute } from './route'; import { getPerformBulkActionEditSchemaMock, - getPerformBulkActionSchemaMock, + getBulkDisableRuleActionSchemaMock, } from '../../../../../../../common/api/detection_engine/rule_management/mocks'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { readRules } from '../../../logic/crud/read_rules'; @@ -45,13 +45,18 @@ describe('Perform bulk action route', () => { ml = mlServicesMock.createSetupContract(); clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit()); + clients.rulesClient.bulkDisableRules.mockResolvedValue({ + rules: [mockRule], + errors: [], + total: 1, + }); performBulkActionRoute(server.router, ml, logger); }); describe('status codes', () => { it('returns 200 when performing bulk action with all dependencies present', async () => { const response = await server.inject( - getBulkActionRequest(), + getBulkDisableRuleActionRequest(), requestContextMock.convertContext(context) ); expect(response.status).toEqual(200); @@ -73,7 +78,7 @@ describe('Perform bulk action route', () => { it("returns 200 when provided filter query doesn't match any rules", async () => { clients.rulesClient.find.mockResolvedValue(getEmptyFindResult()); const response = await server.inject( - getBulkActionRequest(), + getBulkDisableRuleActionRequest(), requestContextMock.convertContext(context) ); expect(response.status).toEqual(200); @@ -97,7 +102,7 @@ describe('Perform bulk action route', () => { getFindResultWithMultiHits({ data: [], total: Infinity }) ); const response = await server.inject( - getBulkActionRequest(), + getBulkDisableRuleActionRequest(), requestContextMock.convertContext(context) ); expect(response.status).toEqual(400); @@ -109,12 +114,22 @@ describe('Perform bulk action route', () => { }); describe('rules execution failures', () => { - it('returns error if disable rule throws error', async () => { - clients.rulesClient.disable.mockImplementation(async () => { - throw new Error('Test error'); + it('returns an error when rulesClient.bulkDisableRules fails', async () => { + clients.rulesClient.bulkDisableRules.mockResolvedValue({ + rules: [], + errors: [ + { + message: 'Test error', + rule: { + id: mockRule.id, + name: mockRule.name, + }, + }, + ], + total: 1, }); const response = await server.inject( - getBulkActionRequest(), + getBulkDisableRuleActionRequest(), requestContextMock.convertContext(context) ); expect(response.status).toEqual(500); @@ -152,7 +167,7 @@ describe('Perform bulk action route', () => { .mockResolvedValue({ valid: false, message: 'mocked validation message' }), }); const response = await server.inject( - getBulkActionRequest(), + getBulkDisableRuleActionRequest(), requestContextMock.convertContext(context) ); @@ -192,7 +207,7 @@ describe('Perform bulk action route', () => { .mockResolvedValue({ valid: false, message: 'mocked validation message' }), }); const response = await server.inject( - { ...getBulkActionRequest(), query: { dry_run: 'true' } }, + { ...getBulkDisableRuleActionRequest(), query: { dry_run: 'true' } }, requestContextMock.convertContext(context) ); @@ -294,11 +309,21 @@ describe('Perform bulk action route', () => { }); it('return error message limited to length of 1000, to prevent large response size', async () => { - clients.rulesClient.disable.mockImplementation(async () => { - throw new Error('a'.repeat(1_300)); + clients.rulesClient.bulkDisableRules.mockResolvedValue({ + rules: [], + errors: [ + { + message: 'a'.repeat(1_300), + rule: { + id: mockRule.id, + name: mockRule.name, + }, + }, + ], + total: 1, }); const response = await server.inject( - getBulkActionRequest(), + getBulkDisableRuleActionRequest(), requestContextMock.convertContext(context) ); expect(response.status).toEqual(500); @@ -314,7 +339,7 @@ describe('Perform bulk action route', () => { method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, body: { - ...getPerformBulkActionSchemaMock(), + ...getBulkDisableRuleActionSchemaMock(), ids: [mockRule.id, 'failed-mock-id'], query: undefined, }, @@ -482,7 +507,7 @@ describe('Perform bulk action route', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, - body: { ...getPerformBulkActionSchemaMock(), action: undefined }, + body: { ...getBulkDisableRuleActionSchemaMock(), action: undefined }, }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( @@ -494,7 +519,7 @@ describe('Perform bulk action route', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, - body: { ...getPerformBulkActionSchemaMock(), action: 'unknown' }, + body: { ...getBulkDisableRuleActionSchemaMock(), action: 'unknown' }, }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( @@ -506,7 +531,7 @@ describe('Perform bulk action route', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, - body: { ...getPerformBulkActionSchemaMock(), query: undefined }, + body: { ...getBulkDisableRuleActionSchemaMock(), query: undefined }, }); const result = server.validate(request); @@ -517,7 +542,7 @@ describe('Perform bulk action route', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, - body: getPerformBulkActionSchemaMock(), + body: getBulkDisableRuleActionSchemaMock(), }); const result = server.validate(request); @@ -528,7 +553,7 @@ describe('Perform bulk action route', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, - body: { ...getPerformBulkActionSchemaMock(), ids: 'test fake' }, + body: { ...getBulkDisableRuleActionSchemaMock(), ids: 'test fake' }, }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( @@ -541,7 +566,7 @@ describe('Perform bulk action route', () => { method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, body: { - ...getPerformBulkActionSchemaMock(), + ...getBulkDisableRuleActionSchemaMock(), query: undefined, ids: Array.from({ length: 101 }).map(() => 'fake-id'), }, @@ -558,7 +583,7 @@ describe('Perform bulk action route', () => { method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, body: { - ...getPerformBulkActionSchemaMock(), + ...getBulkDisableRuleActionSchemaMock(), query: '', ids: ['fake-id'], }, @@ -576,7 +601,7 @@ describe('Perform bulk action route', () => { const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_BULK_ACTION, - body: { ...getPerformBulkActionSchemaMock(), ids: [] }, + body: { ...getBulkDisableRuleActionSchemaMock(), ids: [] }, }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( @@ -620,9 +645,14 @@ describe('Perform bulk action route', () => { total: rulesNumber, }) ); + clients.rulesClient.bulkDisableRules.mockResolvedValue({ + rules: Array.from({ length: rulesNumber }).map(() => mockRule), + errors: [], + total: rulesNumber, + }); const response = await server.inject( - getBulkActionRequest(), + getBulkDisableRuleActionRequest(), requestContextMock.convertContext(context) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index ef06f8f004c98..8dc0a5ec651bb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -5,231 +5,46 @@ * 2.0. */ -import { truncate } from 'lodash'; -import { BadRequestError, transformError } from '@kbn/securitysolution-es-utils'; -import type { IKibanaResponse, KibanaResponseFactory, Logger } from '@kbn/core/server'; - -import type { RulesClient, BulkOperationError } from '@kbn/alerting-plugin/server'; -import type { BulkActionSkipResult } from '@kbn/alerting-plugin/common'; +import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { AbortError } from '@kbn/kibana-utils-plugin/common'; -import type { RuleAlertType } from '../../../../rule_schema'; -import type { BulkActionsDryRunErrCode } from '../../../../../../../common/constants'; -import { - DETECTION_ENGINE_RULES_BULK_ACTION, - MAX_RULES_TO_UPDATE_IN_PARALLEL, - RULES_TABLE_MAX_PAGE_SIZE, -} from '../../../../../../../common/constants'; -import type { - BulkEditActionResponse, - PerformBulkActionResponse, -} from '../../../../../../../common/api/detection_engine/rule_management'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { PerformBulkActionResponse } from '../../../../../../../common/api/detection_engine/rule_management'; import { BulkActionTypeEnum, PerformBulkActionRequestBody, PerformBulkActionRequestQuery, } from '../../../../../../../common/api/detection_engine/rule_management'; -import type { - NormalizedRuleError, - RuleDetailsInError, - BulkEditActionResults, - BulkEditActionSummary, -} from '../../../../../../../common/api/detection_engine'; +import { + DETECTION_ENGINE_RULES_BULK_ACTION, + MAX_RULES_TO_UPDATE_IN_PARALLEL, + RULES_TABLE_MAX_PAGE_SIZE, +} from '../../../../../../../common/constants'; import type { SetupPlugins } from '../../../../../../plugin'; import type { SecuritySolutionPluginRouter } from '../../../../../../types'; import { buildRouteValidationWithZod } from '../../../../../../utils/build_validation/route_validation'; -import { routeLimitedConcurrencyTag } from '../../../../../../utils/route_limited_concurrency_tag'; -import type { PromisePoolError, PromisePoolOutcome } from '../../../../../../utils/promise_pool'; import { initPromisePool } from '../../../../../../utils/promise_pool'; +import { routeLimitedConcurrencyTag } from '../../../../../../utils/route_limited_concurrency_tag'; import { buildMlAuthz } from '../../../../../machine_learning/authz'; -import { deleteRules } from '../../../logic/crud/delete_rules'; -import { duplicateRule } from '../../../logic/actions/duplicate_rule'; -import { duplicateExceptions } from '../../../logic/actions/duplicate_exceptions'; -import { findRules } from '../../../logic/search/find_rules'; -import { readRules } from '../../../logic/crud/read_rules'; -import { getExportByObjectIds } from '../../../logic/export/get_export_by_object_ids'; import { buildSiemResponse } from '../../../../routes/utils'; -import { internalRuleToAPIResponse } from '../../../normalization/rule_converters'; +import type { RuleAlertType } from '../../../../rule_schema'; +import { duplicateExceptions } from '../../../logic/actions/duplicate_exceptions'; +import { duplicateRule } from '../../../logic/actions/duplicate_rule'; import { bulkEditRules } from '../../../logic/bulk_actions/bulk_edit_rules'; -import type { DryRunError } from '../../../logic/bulk_actions/dry_run'; import { - validateBulkEnableRule, - validateBulkDisableRule, - validateBulkDuplicateRule, dryRunValidateBulkEditRule, + validateBulkDuplicateRule, } from '../../../logic/bulk_actions/validations'; +import { deleteRules } from '../../../logic/crud/delete_rules'; +import { getExportByObjectIds } from '../../../logic/export/get_export_by_object_ids'; import { RULE_MANAGEMENT_BULK_ACTION_SOCKET_TIMEOUT_MS } from '../../timeouts'; +import type { BulkActionError } from './bulk_actions_response'; +import { buildBulkResponse } from './bulk_actions_response'; +import { bulkEnableDisableRules } from './bulk_enable_disable_rules'; +import { fetchRulesByQueryOrIds } from './fetch_rules_by_query_or_ids'; -const MAX_RULES_TO_PROCESS_TOTAL = 10000; -const MAX_ERROR_MESSAGE_LENGTH = 1000; +export const MAX_RULES_TO_PROCESS_TOTAL = 10000; const MAX_ROUTE_CONCURRENCY = 5; -export type BulkActionError = - | PromisePoolError - | PromisePoolError - | BulkOperationError; - -const normalizeErrorResponse = (errors: BulkActionError[]): NormalizedRuleError[] => { - const errorsMap = new Map(); - - errors.forEach((errorObj) => { - let message: string; - let statusCode: number = 500; - let errorCode: BulkActionsDryRunErrCode | undefined; - let rule: RuleDetailsInError; - // transform different error types (PromisePoolError | PromisePoolError | BulkOperationError) - // to one common used in NormalizedRuleError - if ('rule' in errorObj) { - rule = errorObj.rule; - message = errorObj.message; - } else { - const { error, item } = errorObj; - const transformedError = - error instanceof Error - ? transformError(error) - : { message: String(error), statusCode: 500 }; - - errorCode = (error as DryRunError)?.errorCode; - message = transformedError.message; - statusCode = transformedError.statusCode; - // The promise pool item is either a rule ID string or a rule object. We have - // string IDs when we fail to fetch rules. Rule objects come from other - // situations when we found a rule but failed somewhere else. - rule = typeof item === 'string' ? { id: item } : { id: item.id, name: item.name }; - } - - if (errorsMap.has(message)) { - errorsMap.get(message)?.rules.push(rule); - } else { - errorsMap.set(message, { - message: truncate(message, { length: MAX_ERROR_MESSAGE_LENGTH }), - status_code: statusCode, - err_code: errorCode, - rules: [rule], - }); - } - }); - - return Array.from(errorsMap, ([_, normalizedError]) => normalizedError); -}; - -const buildBulkResponse = ( - response: KibanaResponseFactory, - { - isDryRun = false, - errors = [], - updated = [], - created = [], - deleted = [], - skipped = [], - }: { - isDryRun?: boolean; - errors?: BulkActionError[]; - updated?: RuleAlertType[]; - created?: RuleAlertType[]; - deleted?: RuleAlertType[]; - skipped?: BulkActionSkipResult[]; - } -): IKibanaResponse => { - const numSucceeded = updated.length + created.length + deleted.length; - const numSkipped = skipped.length; - const numFailed = errors.length; - - const summary: BulkEditActionSummary = { - failed: numFailed, - succeeded: numSucceeded, - skipped: numSkipped, - total: numSucceeded + numFailed + numSkipped, - }; - - // if response is for dry_run, empty lists of rules returned, as rules are not actually updated and stored within ES - // thus, it's impossible to return reliably updated/duplicated/deleted rules - const results: BulkEditActionResults = isDryRun - ? { - updated: [], - created: [], - deleted: [], - skipped: [], - } - : { - updated: updated.map((rule) => internalRuleToAPIResponse(rule)), - created: created.map((rule) => internalRuleToAPIResponse(rule)), - deleted: deleted.map((rule) => internalRuleToAPIResponse(rule)), - skipped, - }; - - if (numFailed > 0) { - return response.custom({ - headers: { 'content-type': 'application/json' }, - body: { - message: summary.succeeded > 0 ? 'Bulk edit partially failed' : 'Bulk edit failed', - status_code: 500, - attributes: { - errors: normalizeErrorResponse(errors), - results, - summary, - }, - }, - statusCode: 500, - }); - } - - const responseBody: BulkEditActionResponse = { - success: true, - rules_count: summary.total, - attributes: { results, summary }, - }; - - return response.ok({ body: responseBody }); -}; - -const fetchRulesByQueryOrIds = async ({ - query, - ids, - rulesClient, - abortSignal, -}: { - query: string | undefined; - ids: string[] | undefined; - rulesClient: RulesClient; - abortSignal: AbortSignal; -}): Promise> => { - if (ids) { - return initPromisePool({ - concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, - items: ids, - executor: async (id: string) => { - const rule = await readRules({ id, rulesClient, ruleId: undefined }); - if (rule == null) { - throw Error('Rule not found'); - } - return rule; - }, - abortSignal, - }); - } - - const { data, total } = await findRules({ - rulesClient, - perPage: MAX_RULES_TO_PROCESS_TOTAL, - filter: query, - page: undefined, - sortField: undefined, - sortOrder: undefined, - fields: undefined, - }); - - if (total > MAX_RULES_TO_PROCESS_TOTAL) { - throw new BadRequestError( - `More than ${MAX_RULES_TO_PROCESS_TOTAL} rules matched the filter query. Try to narrow it down.` - ); - } - - return { - results: data.map((rule) => ({ item: rule.id, result: rule })), - errors: [], - }; -}; - export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'], @@ -256,6 +71,7 @@ export const performBulkActionRoute = ( }, }, }, + async (context, request, response): Promise> => { const { body } = request; const siemResponse = buildSiemResponse(response); @@ -302,9 +118,9 @@ export const performBulkActionRoute = ( const rulesClient = ctx.alerting.getRulesClient(); const exceptionsClient = ctx.lists?.getExceptionListClient(); const savedObjectsClient = ctx.core.savedObjects.client; - const actionsClient = (await ctx.actions)?.getActionsClient(); + const actionsClient = ctx.actions.getActionsClient(); - const { getExporter, getClient } = (await ctx.core).savedObjects; + const { getExporter, getClient } = ctx.core.savedObjects; const client = getClient({ includedHiddenTypes: ['action'] }); const exporter = getExporter(client); @@ -344,69 +160,38 @@ export const performBulkActionRoute = ( }); const rules = fetchRulesOutcome.results.map(({ result }) => result); - let bulkActionOutcome: PromisePoolOutcome; + const errors: BulkActionError[] = [...fetchRulesOutcome.errors]; let updated: RuleAlertType[] = []; let created: RuleAlertType[] = []; let deleted: RuleAlertType[] = []; switch (body.action) { - case BulkActionTypeEnum.enable: - bulkActionOutcome = await initPromisePool({ - concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, - items: rules, - executor: async (rule) => { - await validateBulkEnableRule({ mlAuthz, rule }); - - // during dry run only validation is getting performed and rule is not saved in ES, thus return early - if (isDryRun) { - return rule; - } - - if (!rule.enabled) { - await rulesClient.enable({ id: rule.id }); - } - - return { - ...rule, - enabled: true, - }; - }, - abortSignal: abortController.signal, + case BulkActionTypeEnum.enable: { + const { updatedRules, errors: bulkActionErrors } = await bulkEnableDisableRules({ + rules, + isDryRun, + rulesClient, + action: 'enable', + mlAuthz, }); - updated = bulkActionOutcome.results - .map(({ result }) => result) - .filter((rule): rule is RuleAlertType => rule !== null); + errors.push(...bulkActionErrors); + updated = updatedRules; break; - case BulkActionTypeEnum.disable: - bulkActionOutcome = await initPromisePool({ - concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, - items: rules, - executor: async (rule) => { - await validateBulkDisableRule({ mlAuthz, rule }); - - // during dry run only validation is getting performed and rule is not saved in ES, thus return early - if (isDryRun) { - return rule; - } - - if (rule.enabled) { - await rulesClient.disable({ id: rule.id }); - } - - return { - ...rule, - enabled: false, - }; - }, - abortSignal: abortController.signal, + } + case BulkActionTypeEnum.disable: { + const { updatedRules, errors: bulkActionErrors } = await bulkEnableDisableRules({ + rules, + isDryRun, + rulesClient, + action: 'disable', + mlAuthz, }); - updated = bulkActionOutcome.results - .map(({ result }) => result) - .filter((rule): rule is RuleAlertType => rule !== null); + errors.push(...bulkActionErrors); + updated = updatedRules; break; - - case BulkActionTypeEnum.delete: - bulkActionOutcome = await initPromisePool({ + } + case BulkActionTypeEnum.delete: { + const bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, executor: async (rule) => { @@ -424,13 +209,14 @@ export const performBulkActionRoute = ( }, abortSignal: abortController.signal, }); + errors.push(...bulkActionOutcome.errors); deleted = bulkActionOutcome.results .map(({ item }) => item) .filter((rule): rule is RuleAlertType => rule !== null); break; - - case BulkActionTypeEnum.duplicate: - bulkActionOutcome = await initPromisePool({ + } + case BulkActionTypeEnum.duplicate: { + const bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, executor: async (rule) => { @@ -483,12 +269,13 @@ export const performBulkActionRoute = ( }, abortSignal: abortController.signal, }); + errors.push(...bulkActionOutcome.errors); created = bulkActionOutcome.results .map(({ result }) => result) .filter((rule): rule is RuleAlertType => rule !== null); break; - - case BulkActionTypeEnum.export: + } + case BulkActionTypeEnum.export: { const exported = await getExportByObjectIds( rulesClient, exceptionsClient, @@ -507,11 +294,12 @@ export const performBulkActionRoute = ( }, body: responseBody, }); + } // will be processed only when isDryRun === true // during dry run only validation is getting performed and rule is not saved in ES - case BulkActionTypeEnum.edit: - bulkActionOutcome = await initPromisePool({ + case BulkActionTypeEnum.edit: { + const bulkActionOutcome = await initPromisePool({ concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, items: rules, executor: async (rule) => { @@ -521,9 +309,12 @@ export const performBulkActionRoute = ( }, abortSignal: abortController.signal, }); + errors.push(...bulkActionOutcome.errors); updated = bulkActionOutcome.results .map(({ result }) => result) .filter((rule): rule is RuleAlertType => rule !== null); + break; + } } if (abortController.signal.aborted === true) { @@ -534,7 +325,7 @@ export const performBulkActionRoute = ( updated, deleted, created, - errors: [...fetchRulesOutcome.errors, ...bulkActionOutcome.errors], + errors, isDryRun, }); } catch (err) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts index 74fc6f91d37c4..806c90e41ac12 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/validations.ts @@ -55,9 +55,7 @@ const throwMlAuthError = (mlAuthz: MlAuthz, ruleType: RuleType) => * @param params - {@link BulkActionsValidationArgs} */ export const validateBulkEnableRule = async ({ rule, mlAuthz }: BulkActionsValidationArgs) => { - if (!rule.enabled) { - await throwMlAuthError(mlAuthz, rule.params.type); - } + await throwMlAuthError(mlAuthz, rule.params.type); }; /** @@ -65,9 +63,7 @@ export const validateBulkEnableRule = async ({ rule, mlAuthz }: BulkActionsValid * @param params - {@link BulkActionsValidationArgs} */ export const validateBulkDisableRule = async ({ rule, mlAuthz }: BulkActionsValidationArgs) => { - if (rule.enabled) { - await throwMlAuthError(mlAuthz, rule.params.type); - } + await throwMlAuthError(mlAuthz, rule.params.type); }; /** diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action_ess.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action_ess.ts index 83a7db27fe3fe..a70df8eb58335 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action_ess.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action_ess.ts @@ -6,57 +6,38 @@ */ import { Rule } from '@kbn/alerting-plugin/common'; -import { BaseRuleParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_schema'; import expect from '@kbn/expect'; -import { getCreateEsqlRulesSchemaMock } from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema/mocks'; -import { - DETECTION_ENGINE_RULES_BULK_ACTION, - DETECTION_ENGINE_RULES_URL, -} from '@kbn/security-solution-plugin/common/constants'; import type { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { getCreateEsqlRulesSchemaMock } from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema/mocks'; import { - BulkActionTypeEnum, BulkActionEditTypeEnum, + BulkActionTypeEnum, } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management'; +import { BaseRuleParams } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_schema'; +import { + createRule, + deleteAllRules, + waitForRuleSuccess, +} from '../../../../../../common/utils/security_solution'; +import { FtrProviderContext } from '../../../../../ftr_provider_context'; import { binaryToString, + checkInvestigationFieldSoValue, createLegacyRuleAction, - getLegacyActionSO, - getSimpleRule, - getWebHookAction, createRuleThroughAlertingEndpoint, + getLegacyActionSO, getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, - checkInvestigationFieldSoValue, getRuleSOById, + getSimpleRule, + getWebHookAction, } from '../../../utils'; -import { - createRule, - createAlertsIndex, - deleteAllRules, - deleteAllAlerts, - waitForRuleSuccess, -} from '../../../../../../common/utils/security_solution'; - -import { FtrProviderContext } from '../../../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const securitySolutionApi = getService('securitySolutionApi'); const es = getService('es'); const log = getService('log'); - const esArchiver = getService('esArchiver'); - - const postBulkAction = () => - supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31'); - - const fetchRule = (ruleId: string) => - supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31'); const createConnector = async (payload: Record) => (await supertest.post('/api/actions/action').set('kbn-xsrf', 'true').send(payload).expect(200)) @@ -67,14 +48,7 @@ export default ({ getService }: FtrProviderContext): void => { // Failing: See https://github.com/elastic/kibana/issues/173804 describe('@ess perform_bulk_action - ESS specific logic', () => { beforeEach(async () => { - await createAlertsIndex(supertest, log); - await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); - }); - - afterEach(async () => { - await deleteAllAlerts(supertest, log, es); await deleteAllRules(supertest, log); - await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); }); it('should delete rules and any associated legacy actions', async () => { @@ -99,8 +73,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(sidecarActionsResults.hits.hits.length).to.eql(1); expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).to.eql(rule1.id); - const { body } = await postBulkAction() - .send({ query: '', action: BulkActionTypeEnum.delete }) + const { body } = await securitySolutionApi + .performBulkAction({ + body: { query: '', action: BulkActionTypeEnum.delete }, + query: {}, + }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); @@ -113,7 +90,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(sidecarActionsPostResults.hits.hits.length).to.eql(0); // Check that the updates have been persisted - await fetchRule(ruleId).expect(404); + await securitySolutionApi.readRule({ query: { rule_id: ruleId } }).expect(404); }); it('should enable rules and migrate actions', async () => { @@ -138,8 +115,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(sidecarActionsResults.hits.hits.length).to.eql(1); expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).to.eql(rule1.id); - const { body } = await postBulkAction() - .send({ query: '', action: BulkActionTypeEnum.enable }) + const { body } = await securitySolutionApi + .performBulkAction({ + body: { query: '', action: BulkActionTypeEnum.enable }, + query: {}, + }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); @@ -148,7 +128,9 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.attributes.results.updated[0].enabled).to.eql(true); // Check that the updates have been persisted - const { body: ruleBody } = await fetchRule(ruleId).expect(200); + const { body: ruleBody } = await securitySolutionApi + .readRule({ query: { rule_id: ruleId } }) + .expect(200); // legacy sidecar action should be gone const sidecarActionsPostResults = await getLegacyActionSO(es); @@ -193,8 +175,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(sidecarActionsResults.hits.hits.length).to.eql(1); expect(sidecarActionsResults.hits.hits[0]?._source?.references[0].id).to.eql(rule1.id); - const { body } = await postBulkAction() - .send({ query: '', action: BulkActionTypeEnum.disable }) + const { body } = await securitySolutionApi + .performBulkAction({ + body: { query: '', action: BulkActionTypeEnum.disable }, + query: {}, + }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 1, total: 1 }); @@ -203,7 +188,9 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.attributes.results.updated[0].enabled).to.eql(false); // Check that the updates have been persisted - const { body: ruleBody } = await fetchRule(ruleId).expect(200); + const { body: ruleBody } = await securitySolutionApi + .readRule({ query: { rule_id: ruleId } }) + .expect(200); // legacy sidecar action should be gone const sidecarActionsPostResults = await getLegacyActionSO(es); @@ -248,11 +235,14 @@ export default ({ getService }: FtrProviderContext): void => { ruleToDuplicate.id ); - const { body } = await postBulkAction() - .send({ - query: '', - action: BulkActionTypeEnum.duplicate, - duplicate: { include_exceptions: false, include_expired_exceptions: false }, + const { body } = await securitySolutionApi + .performBulkAction({ + body: { + query: '', + action: BulkActionTypeEnum.duplicate, + duplicate: { include_exceptions: false, include_expired_exceptions: false }, + }, + query: {}, }) .expect(200); @@ -262,10 +252,8 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.attributes.results.created[0].name).to.eql(`${ruleToDuplicate.name} [Duplicate]`); // Check that the updates have been persisted - const { body: rulesResponse } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}/_find`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') + const { body: rulesResponse } = await securitySolutionApi + .findRules({ query: {} }) .expect(200); expect(rulesResponse.total).to.eql(2); @@ -293,16 +281,19 @@ export default ({ getService }: FtrProviderContext): void => { it('should return error if index patterns action is applied to ES|QL rule', async () => { const esqlRule = await createRule(supertest, log, getCreateEsqlRulesSchemaMock()); - const { body } = await postBulkAction() - .send({ - ids: [esqlRule.id], - action: BulkActionTypeEnum.edit, - [BulkActionTypeEnum.edit]: [ - { - type: BulkActionEditTypeEnum.add_index_patterns, - value: ['index-*'], - }, - ], + const { body } = await securitySolutionApi + .performBulkAction({ + body: { + ids: [esqlRule.id], + action: BulkActionTypeEnum.edit, + [BulkActionTypeEnum.edit]: [ + { + type: BulkActionEditTypeEnum.add_index_patterns, + value: ['index-*'], + }, + ], + }, + query: {}, }) .expect(500); @@ -345,15 +336,18 @@ export default ({ getService }: FtrProviderContext): void => { ruleToDuplicate.id ); - const { body: setTagsBody } = await postBulkAction().send({ - query: '', - action: BulkActionTypeEnum.edit, - [BulkActionTypeEnum.edit]: [ - { - type: BulkActionEditTypeEnum.set_tags, - value: ['reset-tag'], - }, - ], + const { body: setTagsBody } = await securitySolutionApi.performBulkAction({ + body: { + query: '', + action: BulkActionTypeEnum.edit, + [BulkActionTypeEnum.edit]: [ + { + type: BulkActionEditTypeEnum.set_tags, + value: ['reset-tag'], + }, + ], + }, + query: {}, }); expect(setTagsBody.attributes.summary).to.eql({ failed: 0, @@ -363,7 +357,9 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updates have been persisted - const { body: setTagsRule } = await fetchRule(ruleId).expect(200); + const { body: setTagsRule } = await securitySolutionApi + .readRule({ query: { rule_id: ruleId } }) + .expect(200); // Sidecar should be removed const sidecarActionsPostResults = await getLegacyActionSO(es); @@ -422,24 +418,27 @@ export default ({ getService }: FtrProviderContext): void => { createdRule.id ); - const { body } = await postBulkAction() - .send({ - ids: [createdRule.id], - action: BulkActionTypeEnum.edit, - [BulkActionTypeEnum.edit]: [ - { - type: BulkActionEditTypeEnum.set_rule_actions, - value: { - throttle: '1h', - actions: [ - { - ...webHookActionMock, - id: webHookConnector.id, - }, - ], + const { body } = await securitySolutionApi + .performBulkAction({ + body: { + ids: [createdRule.id], + action: BulkActionTypeEnum.edit, + [BulkActionTypeEnum.edit]: [ + { + type: BulkActionEditTypeEnum.set_rule_actions, + value: { + throttle: '1h', + actions: [ + { + ...webHookActionMock, + id: webHookConnector.id, + }, + ], + }, }, - }, - ], + ], + }, + query: {}, }) .expect(200); @@ -457,7 +456,9 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.attributes.results.updated[0].actions).to.eql(expectedRuleActions); // Check that the updates have been persisted - const { body: readRule } = await fetchRule(ruleId).expect(200); + const { body: readRule } = await securitySolutionApi + .readRule({ query: { rule_id: ruleId } }) + .expect(200); expect(readRule.actions).to.eql(expectedRuleActions); @@ -475,9 +476,6 @@ export default ({ getService }: FtrProviderContext): void => { let ruleWithIntendedInvestigationField: RuleResponse; beforeEach(async () => { - await deleteAllAlerts(supertest, log, es); - await deleteAllRules(supertest, log); - await createAlertsIndex(supertest, log); ruleWithLegacyInvestigationField = await createRuleThroughAlertingEndpoint( supertest, getRuleSavedObjectWithLegacyInvestigationFields() @@ -495,13 +493,12 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - afterEach(async () => { - await deleteAllRules(supertest, log); - }); - it('should export rules with legacy investigation_fields and transform legacy field in response', async () => { - const { body } = await postBulkAction() - .send({ query: '', action: BulkActionTypeEnum.export }) + const { body } = await securitySolutionApi + .performBulkAction({ + body: { query: '', action: BulkActionTypeEnum.export }, + query: {}, + }) .expect(200) .expect('Content-Type', 'application/ndjson') .expect('Content-Disposition', 'attachment; filename="rules_export.ndjson"') @@ -566,8 +563,11 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should delete rules with investigation fields and transform legacy field in response', async () => { - const { body } = await postBulkAction() - .send({ query: '', action: BulkActionTypeEnum.delete }) + const { body } = await securitySolutionApi + .performBulkAction({ + body: { query: '', action: BulkActionTypeEnum.delete }, + query: {}, + }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 3, total: 3 }); @@ -590,14 +590,25 @@ export default ({ getService }: FtrProviderContext): void => { }); // Check that the updates have been persisted - await fetchRule(ruleWithLegacyInvestigationField.params.ruleId).expect(404); - await fetchRule(ruleWithLegacyInvestigationFieldEmptyArray.params.ruleId).expect(404); - await fetchRule('rule-with-investigation-field').expect(404); + await securitySolutionApi + .readRule({ query: { rule_id: ruleWithLegacyInvestigationField.params.ruleId } }) + .expect(404); + await securitySolutionApi + .readRule({ + query: { rule_id: ruleWithLegacyInvestigationFieldEmptyArray.params.ruleId }, + }) + .expect(404); + await securitySolutionApi + .readRule({ query: { rule_id: 'rule-with-investigation-field' } }) + .expect(404); }); it('should enable rules with legacy investigation fields and transform legacy field in response', async () => { - const { body } = await postBulkAction() - .send({ query: '', action: BulkActionTypeEnum.enable }) + const { body } = await securitySolutionApi + .performBulkAction({ + body: { query: '', action: BulkActionTypeEnum.enable }, + query: {}, + }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 3, total: 3 }); @@ -664,8 +675,8 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should disable rules with legacy investigation fields and transform legacy field in response', async () => { - const { body } = await postBulkAction() - .send({ query: '', action: BulkActionTypeEnum.disable }) + const { body } = await securitySolutionApi + .performBulkAction({ body: { query: '', action: BulkActionTypeEnum.disable }, query: {} }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, skipped: 0, succeeded: 3, total: 3 }); @@ -735,11 +746,14 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should duplicate rules with legacy investigation fields and transform field in response', async () => { - const { body } = await postBulkAction() - .send({ - query: '', - action: BulkActionTypeEnum.duplicate, - duplicate: { include_exceptions: false, include_expired_exceptions: false }, + const { body } = await securitySolutionApi + .performBulkAction({ + body: { + query: '', + action: BulkActionTypeEnum.duplicate, + duplicate: { include_exceptions: false, include_expired_exceptions: false }, + }, + query: {}, }) .expect(200); @@ -754,10 +768,8 @@ export default ({ getService }: FtrProviderContext): void => { expect(names.includes('Test investigation fields object [Duplicate]')).to.eql(true); // Check that the updates have been persisted - const { body: rulesResponse } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}/_find`) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') + const { body: rulesResponse } = await await securitySolutionApi + .findRules({ query: {} }) .expect(200); expect(rulesResponse.total).to.eql(6); @@ -854,15 +866,18 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should edit rules with legacy investigation fields', async () => { - const { body } = await postBulkAction().send({ - query: '', - action: BulkActionTypeEnum.edit, - [BulkActionTypeEnum.edit]: [ - { - type: BulkActionEditTypeEnum.set_tags, - value: ['reset-tag'], - }, - ], + const { body } = await securitySolutionApi.performBulkAction({ + body: { + query: '', + action: BulkActionTypeEnum.edit, + [BulkActionTypeEnum.edit]: [ + { + type: BulkActionEditTypeEnum.set_tags, + value: ['reset-tag'], + }, + ], + }, + query: {}, }); expect(body.attributes.summary).to.eql({ failed: 0,